@bitkyc08/opencodex 2.69.0 → 2.70.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 (147) hide show
  1. package/gui/dist/assets/App-_MrbTrBe.js +51 -0
  2. package/gui/dist/assets/{Tray-uqKRqfvw.js → Tray-5PuUalET.js} +1 -1
  3. package/gui/dist/assets/index-DKrO2eF3.js +86 -0
  4. package/gui/dist/assets/quota-summary-BCLivc0Q.js +1 -0
  5. package/gui/dist/index.html +1 -1
  6. package/package.json +1 -1
  7. package/src/adapters/anthropic-model-contract.ts +119 -0
  8. package/src/adapters/anthropic.ts +14 -83
  9. package/src/adapters/base.ts +2 -0
  10. package/src/adapters/command-code-project-context.ts +542 -0
  11. package/src/adapters/command-code-tool-text.ts +99 -67
  12. package/src/adapters/command-code.ts +5 -1
  13. package/src/adapters/cursor/catalog.ts +11 -0
  14. package/src/adapters/cursor/effort-map.ts +3 -0
  15. package/src/adapters/devin/cloud-direct/catalog.ts +91 -0
  16. package/src/adapters/devin/cloud-direct/chat.ts +78 -48
  17. package/src/adapters/devin/cloud-direct/tool-schema.ts +121 -0
  18. package/src/adapters/devin/context-overflow.ts +75 -0
  19. package/src/adapters/devin/live-models.ts +174 -13
  20. package/src/adapters/devin/reasoning-signature.ts +100 -0
  21. package/src/adapters/devin.ts +310 -117
  22. package/src/adapters/kiro/payload.ts +10 -10
  23. package/src/adapters/kiro-images.ts +58 -7
  24. package/src/adapters/openai-chat/passthrough.ts +3 -1
  25. package/src/adapters/openai-chat.ts +2 -1
  26. package/src/adapters/openai-responses/passthrough.ts +2 -1
  27. package/src/bridge/response-json.ts +15 -1
  28. package/src/bridge/sse.ts +3 -1
  29. package/src/claude/intercept/model-bindings.ts +1 -0
  30. package/src/cli/account-extended.ts +31 -1
  31. package/src/cli/account.ts +3 -0
  32. package/src/cli/capabilities.ts +13 -0
  33. package/src/cli/client-start-fence.ts +36 -0
  34. package/src/cli/dispatch.ts +3 -2
  35. package/src/cli/index.ts +98 -117
  36. package/src/cli/provider-runtime.ts +23 -2
  37. package/src/cli/provider.ts +3 -0
  38. package/src/cli/resolve.ts +18 -0
  39. package/src/cli/start-owner-fence.ts +30 -0
  40. package/src/cli/stop-approval.ts +13 -1
  41. package/src/cli/stop-restore.ts +73 -0
  42. package/src/cli/tray-proxy.ts +221 -18
  43. package/src/cli/version-skew.ts +25 -23
  44. package/src/client/runtime.ts +8 -4
  45. package/src/codex/auth-api/account-list.ts +11 -5
  46. package/src/codex/auth-api/login-flow.ts +5 -3
  47. package/src/codex/auth-api/main-account-probe.ts +186 -110
  48. package/src/codex/auth-api/pool-mode-gate.ts +61 -7
  49. package/src/codex/auth-api/pool-quota-probe.ts +142 -90
  50. package/src/codex/auth-api/reset-credit-service.ts +1 -1
  51. package/src/codex/catalog/gather-capture.ts +1 -0
  52. package/src/codex/catalog/model-hints.ts +3 -3
  53. package/src/codex/catalog/provider-models.ts +2 -0
  54. package/src/codex/low-quota-events.ts +28 -0
  55. package/src/codex/low-quota-observer.ts +23 -0
  56. package/src/codex/low-quota-protection.ts +272 -0
  57. package/src/codex/quota-query-backoff.ts +148 -0
  58. package/src/codex/quota.ts +7 -2
  59. package/src/config/diagnostics.ts +10 -1
  60. package/src/config/load-degrade.ts +17 -0
  61. package/src/config/mutation-lock.ts +3 -4
  62. package/src/config/process-state.ts +1 -1
  63. package/src/config/provider-validation.ts +22 -0
  64. package/src/config/schema/blocked-model-redirects.ts +17 -0
  65. package/src/config/schema/config-schema.ts +4 -1
  66. package/src/config/schema/leaf-validators.ts +33 -2
  67. package/src/config/serving-runtimes.ts +412 -0
  68. package/src/generated/compatibility-version.json +211 -115
  69. package/src/generated/model-metadata.ts +3 -3
  70. package/src/images/loop.ts +3 -0
  71. package/src/lib/errors.ts +2 -1
  72. package/src/lib/handled-signal-exit.ts +23 -0
  73. package/src/lib/request-failure-model.ts +1 -0
  74. package/src/lib/self-launch-argv.ts +9 -0
  75. package/src/lib/shadow-call.ts +5 -2
  76. package/src/lib/strict-semver.ts +26 -0
  77. package/src/lib/transient-service-path.ts +16 -0
  78. package/src/lib/windows-service-wrappers.ts +10 -6
  79. package/src/lib/winsw.ts +3 -1
  80. package/src/oauth/anthropic-model-routes.ts +105 -0
  81. package/src/oauth/anthropic-routing.ts +85 -48
  82. package/src/oauth/devin.ts +135 -5
  83. package/src/oauth/index.ts +20 -3
  84. package/src/oauth/kiro-terminal-failover.ts +23 -9
  85. package/src/oauth/pool-settings-capability.ts +10 -2
  86. package/src/oauth/store.ts +1 -1
  87. package/src/protocols/encoders/adapter-events.ts +3 -1
  88. package/src/providers/github-copilot-context.ts +27 -0
  89. package/src/providers/kiro-models.ts +4 -0
  90. package/src/providers/model-rename-fields.ts +3 -0
  91. package/src/providers/quota/account-cache.ts +8 -3
  92. package/src/providers/quota/devin.ts +286 -0
  93. package/src/providers/quota/vendor-probes-oauth.ts +6 -2
  94. package/src/providers/quota.ts +1 -1
  95. package/src/providers/registry/entries-core.ts +1 -1
  96. package/src/providers/registry/model-seeds.ts +5 -2
  97. package/src/responses/compaction-images.ts +27 -0
  98. package/src/router.ts +104 -16
  99. package/src/routing/analytics.ts +1 -0
  100. package/src/server/auth-cors.ts +9 -1
  101. package/src/server/background-lifecycle.ts +21 -10
  102. package/src/server/chat-completions.ts +2 -1
  103. package/src/server/chat-native.ts +1 -0
  104. package/src/server/index.ts +4 -8
  105. package/src/server/inference/client-encoder-delivery.ts +3 -0
  106. package/src/server/management/context.ts +3 -0
  107. package/src/server/management/low-quota-routes.ts +14 -0
  108. package/src/server/management/oauth-account-routes.ts +21 -1
  109. package/src/server/management/provider-overwrite-carry.ts +79 -6
  110. package/src/server/management/provider-routes.ts +41 -28
  111. package/src/server/management/route-registry.ts +1 -0
  112. package/src/server/management-api.ts +7 -0
  113. package/src/server/responses/adapter-continuation.ts +3 -0
  114. package/src/server/responses/adapter-delivery.ts +3 -0
  115. package/src/server/responses/adapter-dispatch.ts +20 -4
  116. package/src/server/responses/antigravity-validation-refusal.ts +20 -0
  117. package/src/server/responses/core-auth.ts +1 -0
  118. package/src/server/responses/core-codex-account.ts +1 -0
  119. package/src/server/responses/core-normalize.ts +4 -0
  120. package/src/server/responses/passthrough-dispatch.ts +8 -1
  121. package/src/server/responses/request-sidecar-auth.ts +2 -0
  122. package/src/server/responses/request-transport.ts +27 -10
  123. package/src/server/responses/response-effects.ts +4 -1
  124. package/src/server/responses/run-turn-execution.ts +126 -42
  125. package/src/server/responses/sidecar-execution.ts +4 -1
  126. package/src/service/diagnostics.ts +3 -1
  127. package/src/service/guarded-manager-target.ts +39 -12
  128. package/src/service/launchd.ts +14 -6
  129. package/src/service/repair.ts +6 -1
  130. package/src/service/service-child-ownership.ts +168 -0
  131. package/src/service/state.ts +22 -4
  132. package/src/service/systemd.ts +2 -2
  133. package/src/service/windows-guarded-manager.ts +530 -0
  134. package/src/service/windows-taskxml.ts +2 -2
  135. package/src/service.ts +2 -0
  136. package/src/types/config.ts +26 -0
  137. package/src/types/provider.ts +12 -0
  138. package/src/types/request.ts +2 -0
  139. package/src/types.ts +1 -0
  140. package/src/usage/expected-prices.ts +13 -0
  141. package/src/usage/telemetry-contract.ts +1 -0
  142. package/src/vision/anthropic-describe.ts +2 -1
  143. package/src/web-search/anthropic-executor.ts +2 -1
  144. package/src/web-search/loop.ts +1 -0
  145. package/gui/dist/assets/App-DDbgOTh6.js +0 -51
  146. package/gui/dist/assets/index-CuaBJkG4.js +0 -86
  147. package/gui/dist/assets/quota-summary-COppQSVq.js +0 -1
@@ -10,6 +10,7 @@ import {
10
10
  normalizeNonBlankStringArray,
11
11
  normalizeAutoReviewModelOverrides,
12
12
  modelCapabilitiesConfigError,
13
+ contextTierRecordConfigError,
13
14
  mergeModelCapabilities,
14
15
  } from "../provider-validation";
15
16
  import { isValidCodexAccountNamespaceTarget } from "../../codex/account-namespace-match";
@@ -92,7 +93,7 @@ export const retryOn429PolicySchema = z.object({
92
93
  * both retry layers, so the ceiling is deliberately lower than `retryOn429`'s: 10 total sends
93
94
  * against an already-failing provider is already generous.
94
95
  */
95
- const transientRetryOn5xxPolicySchema = z.object({
96
+ export const transientRetryOn5xxPolicySchema = z.object({
96
97
  enabled: z.boolean().optional(),
97
98
  attempts: z.number().int().min(1).max(10).optional(),
98
99
  }).strict();
@@ -279,6 +280,10 @@ const providerNoProxySchema = z.unknown().superRefine((value, ctx) => {
279
280
  */
280
281
  export const providerConfigSchema = z.object({
281
282
  modelCapabilities: modelCapabilitiesSchema.optional(),
283
+ modelContextTiers: z.unknown().superRefine((value, ctx) => {
284
+ const error = contextTierRecordConfigError(value);
285
+ if (error) ctx.addIssue({ code: "custom", message: error });
286
+ }).optional().transform(value => value as OcxProviderConfig["modelContextTiers"]),
282
287
  pinnedReasoningEffort: pinnedReasoningEffortSchema.optional(),
283
288
  modelPinnedReasoningEfforts: modelPinnedEffortsSchema.optional(),
284
289
  // Validated rather than left to passthrough: an unrecognized strategy would otherwise
@@ -377,6 +382,7 @@ export const providerConfigSchema = z.object({
377
382
  // accepted, persisted, and then silently resolved to the `code_mode_only` default — the
378
383
  // operator asked for shell mode, got code mode, and was told nothing (#2106).
379
384
  codexToolMode: z.enum(["code_mode_only", "shell"]).optional(),
385
+ projectContext: z.enum(["off", "on"]).optional(),
380
386
  responsesItemIdRepair: z.object({
381
387
  message: z.array(z.string().min(1)).optional(),
382
388
  reasoning: z.array(z.string().min(1)).optional(),
@@ -384,6 +390,7 @@ export const providerConfigSchema = z.object({
384
390
  repairInvalidIds: z.boolean().optional(),
385
391
  }).strict().optional(),
386
392
  responsesSnapshotRepair: z.boolean().optional(),
393
+ hideRawReasoning: z.boolean().optional(),
387
394
  // Invalid blocks degrade to "absent" rather than failing the whole config load: an unusable
388
395
  // bridge block must never send an operator through invalid-config recovery for an opt-in
389
396
  // feature that is off by default. The management write boundary still rejects it loudly.
@@ -391,7 +398,11 @@ export const providerConfigSchema = z.object({
391
398
  xaiResponsesXSearch: z.boolean().optional(),
392
399
  xaiResponsesDefaultVersion: z.number().int().positive().optional().catch(undefined),
393
400
  zaiResponsesDefaultVersion: z.number().int().positive().optional().catch(undefined),
394
- }).passthrough();
401
+ }).passthrough().superRefine((provider, ctx) => {
402
+ if (provider.projectContext !== undefined && provider.adapter !== "command-code") {
403
+ ctx.addIssue({ code: "custom", path: ["projectContext"], message: "projectContext is supported only by the command-code adapter" });
404
+ }
405
+ });
395
406
 
396
407
 
397
408
  export { providerRelativeSendPathConfigError } from "../provider-relative-send-path";
@@ -960,6 +971,26 @@ export const clientConnectionSchema = z.object({
960
971
  export const codexPoolSchema = z.object({
961
972
  excludedPlans: z.array(z.string().trim().min(1)).optional(),
962
973
  startIdleWindows: z.boolean().optional(),
974
+ lowQuotaProtection: z.object({
975
+ enabled: z.boolean(),
976
+ threshold: z.number().finite().min(1).max(100),
977
+ actions: z.object({
978
+ pause: z.boolean(),
979
+ notify: z.boolean(),
980
+ }).strict(),
981
+ windows: z.object({
982
+ short: z.boolean(),
983
+ weekly: z.boolean(),
984
+ }).strict(),
985
+ }).strict().superRefine((policy, ctx) => {
986
+ if (!policy.enabled) return;
987
+ if (!policy.actions.pause && !policy.actions.notify) {
988
+ ctx.addIssue({ code: "custom", path: ["actions"], message: "enabled low-quota protection needs an action" });
989
+ }
990
+ if (!policy.windows.short && !policy.windows.weekly) {
991
+ ctx.addIssue({ code: "custom", path: ["windows"], message: "enabled low-quota protection needs a window" });
992
+ }
993
+ }).optional(),
963
994
  }).strict();
964
995
 
965
996
  /**
@@ -0,0 +1,412 @@
1
+ /**
2
+ * Serving-runtime census: which installations have served this home, newest known.
3
+ *
4
+ * The reported failure shape: the desktop app self-updates its bundled runtime while the
5
+ * npm/mise install the Task Scheduler wrapper was baked against stays behind. When the
6
+ * desktop session ends, the wrapper relaunches its pinned Bun + CLI pair and the machine
7
+ * silently downgrades to the older package — features the operator just gained stop
8
+ * existing, with nothing in the log saying why. The same hazard runs in reverse whenever
9
+ * the packaged install is the stale half.
10
+ *
11
+ * The fix is a small census, not a resolution oracle. Every `ocx start` that reaches the
12
+ * bind boundary records the command that would relaunch it (`argv`-shaped absolute
13
+ * paths) plus its package version. A service child — the path where a fixed definition
14
+ * picks the runtime, instead of the operator's shell — then asks whether a strictly newer
15
+ * recorded install still exists on disk, re-verifies it with a bounded `--version` probe,
16
+ * and hands the serve to it. Because only strictly newer candidates defer, the newest
17
+ * available install converges in one hop and can never ping-pong.
18
+ *
19
+ * Everything here is fail-open toward the recorded install's own runtime: a missing file,
20
+ * a malformed record, or a dead probe means "serve this install", never "stay down". The
21
+ * registry is information a serving proxy wrote about itself; it authorizes relaunching a
22
+ * binary the same installation already ran, nothing more.
23
+ */
24
+ import { spawn, spawnSync } from "node:child_process";
25
+ import { constants as osConstants } from "node:os";
26
+ import { randomUUID } from "node:crypto";
27
+ import { existsSync, mkdirSync, readFileSync, realpathSync, statSync, unlinkSync, writeFileSync } from "node:fs";
28
+ import { isAbsolute, join } from "node:path";
29
+ import { durableBunRuntime } from "../lib/bun-runtime";
30
+ import { selfLaunchArgv } from "../lib/self-launch-argv";
31
+ import { compareStrictSemver, parseStrictSemver } from "../lib/strict-semver";
32
+ import { assertNotRealHomeUnderTest } from "../lib/test-home-guard";
33
+ import { atomicWriteFile } from "./atomic-write";
34
+ import { getConfigDir } from "./paths";
35
+ import { ConfigMutationLockError, withConfigMutationLockSync } from "./mutation-lock";
36
+ import { SERVICE_MANAGED_ENV } from "../service/state";
37
+ import { WINDOWS_WRAPPER_PROTOCOL_ENV } from "../service/windows-wrapper-exit";
38
+
39
+ export function servingRuntimesPath(dir: string = getConfigDir()): string {
40
+ return join(dir, "serving-runtimes.json");
41
+ }
42
+
43
+ /**
44
+ * The argv prefix that relaunches THIS install's proxy: `[exe]` for a compiled
45
+ * standalone binary, `[bun, cli/index.ts]` for a package install. Lives here because
46
+ * the census is its only consumer.
47
+ */
48
+ export function currentServingCommand(): string[] {
49
+ return [durableBunRuntime().path, ...selfLaunchArgv([])]
50
+ .map(part => (isAbsolute(part) ? part : join(process.cwd(), part)));
51
+ }
52
+
53
+ /** One installation's relaunch command and the version it last served. */
54
+ export interface ServedRuntimeRecord {
55
+ /**
56
+ * The argv prefix that relaunches this install's proxy: `[exe]` for a compiled
57
+ * standalone binary, `[bun, cli/index.ts]` for a package install. Absolute paths only.
58
+ */
59
+ readonly command: readonly string[];
60
+ readonly version: string;
61
+ readonly servedAt: string;
62
+ }
63
+
64
+ /** Bound on retained installs; the file is operator-facing state, not a log. */
65
+ const MAX_SERVING_RUNTIME_RECORDS = 16;
66
+
67
+ /**
68
+ * Probe budget for re-verifying a recorded binary. Runs once per service-child start,
69
+ * so a short ceiling keeps a wedged sibling binary from delaying every relaunch.
70
+ */
71
+ const RUNTIME_VERSION_PROBE_TIMEOUT_MS = 3_000;
72
+
73
+ /**
74
+ * Bound on candidate probes per service-child start. The census can retain up to
75
+ * MAX_SERVING_RUNTIME_RECORDS installs and each probe carries its own timeout, so
76
+ * without a cap a registry full of dead records would stall every relaunch by
77
+ * RECORDS × the probe timeout.
78
+ */
79
+ const MAX_VERSION_PROBE_ATTEMPTS = 4;
80
+
81
+ function canonicalPath(path: string): string {
82
+ let resolved = path;
83
+ try { resolved = realpathSync(path); } catch { /* keep the literal path */ }
84
+ return process.platform === "win32" ? resolved.toLowerCase() : resolved;
85
+ }
86
+
87
+ /**
88
+ * Identity of a relaunch command. Two spellings of one binary (junction, mapped drive,
89
+ * case fold) must collide; different installs must not.
90
+ */
91
+ export function servingRuntimeCommandKey(command: readonly string[]): string {
92
+ return command.map(canonicalPath).join("\u0000");
93
+ }
94
+
95
+ function isValidRecord(value: unknown): value is ServedRuntimeRecord {
96
+ if (!value || typeof value !== "object") return false;
97
+ const record = value as Record<string, unknown>;
98
+ if (!Array.isArray(record.command) || record.command.length === 0) return false;
99
+ if (!record.command.every(part => typeof part === "string" && isAbsolute(part))) return false;
100
+ if (typeof record.version !== "string" || parseStrictSemver(record.version) === null) return false;
101
+ return typeof record.servedAt === "string";
102
+ }
103
+
104
+ /** Read the census. Malformed entries are dropped; a malformed file reads as empty. */
105
+ export function readServingRuntimes(dir: string = getConfigDir()): ServedRuntimeRecord[] {
106
+ try {
107
+ const parsed = JSON.parse(readFileSync(servingRuntimesPath(dir), "utf-8"));
108
+ const runtimes = (parsed as Record<string, unknown>)?.runtimes;
109
+ if (!Array.isArray(runtimes)) return [];
110
+ return runtimes.filter(isValidRecord);
111
+ } catch {
112
+ return [];
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Record that this install served this home. Runs only after the bind boundary
118
+ * succeeded: a start that never reached serving must not register a runtime that
119
+ * never actually ran. Best-effort after the entry shape is validated — a census
120
+ * write failure must never take down a healthy start.
121
+ */
122
+ export function recordServingRuntime(
123
+ record: ServedRuntimeRecord,
124
+ dir: string = getConfigDir(),
125
+ ): void {
126
+ if (!isValidRecord(record)) return;
127
+ try {
128
+ assertNotRealHomeUnderTest(dir);
129
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
130
+ // The complete read/merge/replace is one cross-process transaction. Briefly retry
131
+ // SQLITE_BUSY because this best-effort startup write must not drop a concurrent serve.
132
+ for (let attempt = 0; attempt < 20; attempt++) {
133
+ try {
134
+ withConfigMutationLockSync(() => {
135
+ const key = servingRuntimeCommandKey(record.command);
136
+ const merged = [record, ...readServingRuntimes(dir).filter(entry => servingRuntimeCommandKey(entry.command) !== key)];
137
+ atomicWriteFile(servingRuntimesPath(dir), JSON.stringify({ runtimes: merged.slice(0, MAX_SERVING_RUNTIME_RECORDS) }, null, 2) + "\n");
138
+ }, dir);
139
+ break;
140
+ } catch (error) {
141
+ if (!(error instanceof ConfigMutationLockError) || attempt === 19) throw error;
142
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
143
+ }
144
+ }
145
+ } catch { /* census loss must never fail a start */ }
146
+ }
147
+
148
+ export interface SyncRunResult {
149
+ readonly status: number | null;
150
+ readonly stdout: string;
151
+ readonly stderr: string;
152
+ }
153
+
154
+ type SyncRunner = (file: string, args: readonly string[]) => SyncRunResult;
155
+
156
+ function bufferedSyncRunner(file: string, args: readonly string[]): SyncRunResult {
157
+ const result = spawnSync(file, [...args], {
158
+ encoding: "utf8",
159
+ windowsHide: true,
160
+ timeout: RUNTIME_VERSION_PROBE_TIMEOUT_MS,
161
+ });
162
+ return {
163
+ status: result.status,
164
+ stdout: String(result.stdout ?? ""),
165
+ stderr: String(result.stderr ?? ""),
166
+ };
167
+ }
168
+
169
+ /**
170
+ * Extract the strict semver a binary reports for `--version`, or null when it cannot be
171
+ * asked. The probe is the re-verification step: a census record survives the binary it
172
+ * described (a rollback, a partial uninstall), so the version on record alone never
173
+ * authorizes a handoff.
174
+ */
175
+ export function probeServedRuntimeVersion(
176
+ command: readonly string[],
177
+ run: SyncRunner = bufferedSyncRunner,
178
+ ): string | null {
179
+ if (command.length === 0) return null;
180
+ let result: SyncRunResult;
181
+ try {
182
+ result = run(command[0]!, [...command.slice(1), "--version"]);
183
+ } catch {
184
+ return null;
185
+ }
186
+ if (result.status !== 0) return null;
187
+ // printVersion() emits "opencodex X.Y.Z"; accept the strict-semver token wherever it lands.
188
+ const match = /(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)/.exec(result.stdout);
189
+ if (!match) return null;
190
+ return parseStrictSemver(match[1])?.raw ?? null;
191
+ }
192
+
193
+ /** Reject a substituted launch target from home-local census state. */
194
+ function trustedRecordedPath(path: string): boolean {
195
+ try {
196
+ if (!isAbsolute(path)) return false;
197
+ const info = statSync(realpathSync(path));
198
+ if (!info.isFile()) return false;
199
+ if (process.platform !== "win32") {
200
+ if (typeof process.getuid === "function" && info.uid !== process.getuid()) return false;
201
+ if ((info.mode & 0o022) !== 0) return false;
202
+ }
203
+ return true;
204
+ } catch {
205
+ return false;
206
+ }
207
+ }
208
+
209
+ export interface NewerServingRuntimeDeps {
210
+ readonly dir?: string;
211
+ readonly exists?: (path: string) => boolean;
212
+ readonly run?: SyncRunner;
213
+ }
214
+
215
+ /**
216
+ * The newest recorded install that can actually replace this one, or null.
217
+ *
218
+ * "Recorded" alone never suffices: the probe re-asks the binary for its version, so a
219
+ * record left behind by a replaced or partially removed install demotes itself instead of
220
+ * handing the port to a stale executable. Candidates probe best-recorded-version first
221
+ * and a dead or demoting candidate falls through to the next one — a rolled-back top
222
+ * record must not keep a good install beneath it from serving. Self is excluded by
223
+ * command identity, not by version, so an equal-version sibling is never a candidate.
224
+ */
225
+ export function selectNewerServingRuntime(
226
+ selfVersion: string,
227
+ selfCommand: readonly string[],
228
+ deps: NewerServingRuntimeDeps = {},
229
+ ): ServedRuntimeRecord | null {
230
+ const self = parseStrictSemver(selfVersion);
231
+ if (self === null) return null;
232
+ const exists = deps.exists ?? existsSync;
233
+ const selfKey = servingRuntimeCommandKey(selfCommand);
234
+ const candidates = readServingRuntimes(deps.dir ?? getConfigDir())
235
+ .filter(record => servingRuntimeCommandKey(record.command) !== selfKey)
236
+ .filter(record => {
237
+ const recorded = parseStrictSemver(record.version);
238
+ return recorded !== null && compareStrictSemver(recorded, self) > 0;
239
+ })
240
+ .filter(record => record.command.every(part => exists(part) && trustedRecordedPath(part)))
241
+ .sort((left, right) => compareStrictSemver(parseStrictSemver(right.version)!, parseStrictSemver(left.version)!));
242
+ // Probe the bounded candidate set and pick the greatest VERIFIED version. The recorded
243
+ // version only orders the probes: a record claiming 2.70 that now answers 2.68 must not win
244
+ // over a sibling that records and answers 2.69, and the one-hop marker means the delegate
245
+ // could never correct that choice itself.
246
+ let best: { record: ServedRuntimeRecord; version: string; semver: NonNullable<ReturnType<typeof parseStrictSemver>> } | null = null;
247
+ for (const record of candidates.slice(0, MAX_VERSION_PROBE_ATTEMPTS)) {
248
+ const probed = probeServedRuntimeVersion(record.command, deps.run);
249
+ const probedSemver = probed === null ? null : parseStrictSemver(probed);
250
+ if (probedSemver === null || compareStrictSemver(probedSemver, self) <= 0) continue;
251
+ if (best === null || compareStrictSemver(probedSemver, best.semver) > 0) best = { record, version: probed!, semver: probedSemver };
252
+ }
253
+ return best === null ? null : { ...best.record, version: best.version };
254
+ }
255
+
256
+ export interface DeferToNewerRuntimeDeps extends NewerServingRuntimeDeps {
257
+ readonly runInherited?: (command: readonly string[], args: readonly string[]) => Promise<DelegatedExit>;
258
+ readonly log?: (line: string) => void;
259
+ /** Environment the service manager gave this child; decides its stay-out exit code. */
260
+ readonly env?: NodeJS.ProcessEnv;
261
+ }
262
+
263
+ interface DelegatedExit { readonly exitCode: number; readonly ready: boolean }
264
+ const DELEGATED_ONCE_ENV = "OCX_DELEGATED_ONCE";
265
+ const DELEGATED_READY_ENV = "OCX_DELEGATED_READY_NONCE";
266
+ const READY_NONCE_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
267
+ function readyMarkerPath(nonce: string): string {
268
+ return join(getConfigDir(), `.delegated-ready-${nonce}`);
269
+ }
270
+
271
+ /** Child publishes bind completion for its waiting service parent. */
272
+ export function markDelegatedServiceReady(): void {
273
+ const nonce = process.env[DELEGATED_READY_ENV];
274
+ if (process.env[DELEGATED_ONCE_ENV] !== "1" || !nonce || !READY_NONCE_RE.test(nonce)) return;
275
+ try { writeFileSync(readyMarkerPath(nonce), String(process.pid), { flag: "wx", mode: 0o600 }); }
276
+ catch { /* parent will conservatively treat an early failed child as unready */ }
277
+ }
278
+
279
+ /**
280
+ * Time a delegated child gets to finish shutting down after this parent takes a
281
+ * termination signal, before escalation to SIGKILL. The parent waits for the child
282
+ * to exit; repeated signals share one timer, which is cleared when that wait ends.
283
+ */
284
+ const DELEGATED_SIGNAL_GRACE_MS = 5_000;
285
+
286
+ /**
287
+ * Run the delegated install in the foreground and wait for its exit.
288
+ *
289
+ * The service manager tracks THIS process, so termination must reach the child:
290
+ * signals are forwarded, and a parent exit (including a manager kill that never
291
+ * signals the child) still terminates it through the exit hook. A manager that
292
+ * force-kills the parent without a signal remains the one uncovered case — the
293
+ * port-holding child then outlives the registration, which the next service start
294
+ * sees through the usual live-owner path.
295
+ */
296
+ async function inheritedRunner(command: readonly string[], args: readonly string[]): Promise<DelegatedExit> {
297
+ const nonce = randomUUID();
298
+ const marker = readyMarkerPath(nonce);
299
+ const child = spawn(command[0]!, [...command.slice(1), ...args], {
300
+ stdio: "inherit",
301
+ windowsHide: true,
302
+ env: { ...process.env, [DELEGATED_ONCE_ENV]: "1", [DELEGATED_READY_ENV]: nonce },
303
+ });
304
+ const terminateChild = () => { try { child.kill(); } catch { /* already gone */ } };
305
+ const forceKillChild = () => { try { child.kill("SIGKILL"); } catch { /* already gone */ } };
306
+ let escalation: ReturnType<typeof setTimeout> | undefined;
307
+ let terminationRequested = false;
308
+ const forward = (signal: NodeJS.Signals) => () => {
309
+ terminationRequested = true;
310
+ try { child.kill(signal); } catch { /* already gone */ }
311
+ escalation ??= setTimeout(forceKillChild, DELEGATED_SIGNAL_GRACE_MS);
312
+ escalation.unref();
313
+ };
314
+ const onSigint = forward("SIGINT");
315
+ const onSigterm = forward("SIGTERM");
316
+ const onSighup = forward("SIGHUP");
317
+ process.on("SIGINT", onSigint);
318
+ process.on("SIGTERM", onSigterm);
319
+ process.on("SIGHUP", onSighup);
320
+ process.on("exit", terminateChild);
321
+ try {
322
+ const exitCode = await new Promise<number>((resolve, reject) => {
323
+ child.once("error", reject);
324
+ child.once("exit", (code, signal) => resolve(
325
+ signal === null ? (code ?? 1) : 128 + (osConstants.signals[signal] ?? 1),
326
+ ));
327
+ });
328
+ let ready = terminationRequested;
329
+ try { ready ||= readFileSync(marker, "utf8") === String(child.pid); } catch { /* pre-bind exit */ }
330
+ return { exitCode, ready };
331
+ } finally {
332
+ try { unlinkSync(marker); } catch { /* absent marker */ }
333
+ if (escalation) clearTimeout(escalation);
334
+ process.off("SIGINT", onSigint);
335
+ process.off("SIGTERM", onSigterm);
336
+ process.off("SIGHUP", onSighup);
337
+ process.off("exit", terminateChild);
338
+ }
339
+ }
340
+
341
+ /**
342
+ * Hand this service child's serve to a strictly newer recorded install.
343
+ *
344
+ * Resolves to the delegated child's exit code when a handoff happened, null when this
345
+ * process should serve itself. The foreground wait keeps the service contract intact:
346
+ * the wrapper sees the real child's exit (including the stay-out code) and restart
347
+ * ownership stays exactly where the manager put it. Only called on the `OCX_SERVICE`
348
+ * path — an interactive `ocx start` already picked its binary on PATH.
349
+ */
350
+ export async function deferToNewerServiceRuntime(
351
+ selfVersion: string,
352
+ selfCommand: readonly string[],
353
+ port: number | undefined,
354
+ deps: DeferToNewerRuntimeDeps = {},
355
+ ): Promise<number | null> {
356
+ const log = deps.log ?? (line => console.log(line));
357
+ const candidate = selectNewerServingRuntime(selfVersion, selfCommand, deps);
358
+ if (candidate === null) return null;
359
+ const startArgs = ["start"];
360
+ if (port !== undefined && Number.isFinite(port) && port > 0 && port <= 65535) {
361
+ startArgs.push("--port", String(Math.trunc(port)));
362
+ }
363
+ const run = deps.runInherited ?? inheritedRunner;
364
+ log(
365
+ `⚠️ This install (${selfVersion}) is older than the runtime that last served this home (${candidate.version}). `
366
+ + `Deferring to ${candidate.command.join(" ")} so the service does not silently downgrade.`,
367
+ );
368
+ try {
369
+ const result = await run(candidate.command, startArgs);
370
+ // Any exit before the delegate published its bind, whatever the code, falls back to this
371
+ // installation. Propagating a pre-bind exit 0 (or the stay-out code) would end the service
372
+ // with nothing serving. Falling back cannot override a deliberate stand-down: this process
373
+ // continues through the same start path, whose lease-held bind fence re-applies every
374
+ // stay-out condition (a live proxy, a foreign recorded owner) before it binds anything.
375
+ if (!result.ready) {
376
+ log(`⚠️ Newer runtime exited before bind (status ${result.exitCode}); serving this install instead.`);
377
+ return null;
378
+ }
379
+ return result.exitCode;
380
+ } catch (error) {
381
+ log(`⚠️ Newer runtime failed to launch (${error instanceof Error ? error.message : String(error)}); serving this install instead.`);
382
+ return null;
383
+ }
384
+ }
385
+
386
+ /**
387
+ * The `handleStart` gate: only a service child defers, and only before it binds.
388
+ * A sibling owns nothing shared, and an interactive start picked its binary on PATH —
389
+ * the manager's baked definition is the one place "which install serves" can drift.
390
+ */
391
+ export async function deferServiceChildToNewerRuntime(options: {
392
+ readonly sibling: boolean;
393
+ readonly env: NodeJS.ProcessEnv;
394
+ readonly selfVersion: string;
395
+ readonly selfCommand: readonly string[];
396
+ readonly port?: number;
397
+ readonly deps?: DeferToNewerRuntimeDeps;
398
+ }): Promise<number | null> {
399
+ if (options.sibling || !isManagedServiceEnvironment(options.env) || options.env[DELEGATED_ONCE_ENV] === "1") return null;
400
+ return deferToNewerServiceRuntime(options.selfVersion, options.selfCommand, options.port, { env: options.env, ...options.deps });
401
+ }
402
+
403
+ /**
404
+ * A child a service manager started, as its environment proves it. launchd, systemd, and
405
+ * current or repaired WinSW definitions write `OCX_SERVICE_MANAGED=1`; the Windows Task
406
+ * Scheduler wrapper writes `OCX_SERVICE=1` with its stay-out protocol marker instead.
407
+ * Bare `OCX_SERVICE=1` never qualifies: `ocx claude` and `ocx opencode` companions carry
408
+ * it, and legacy WinSW definitions without the managed marker do not delegate until repaired.
409
+ */
410
+ export function isManagedServiceEnvironment(env: NodeJS.ProcessEnv): boolean {
411
+ return env[SERVICE_MANAGED_ENV] === "1" || (env.OCX_SERVICE === "1" && env[WINDOWS_WRAPPER_PROTOCOL_ENV] === "1");
412
+ }