@bitkyc08/opencodex 2.10.0 → 2.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (274) hide show
  1. package/AGENTS_INSTALL.md +77 -0
  2. package/README.md +4 -10
  3. package/bin/ocx.mjs +71 -18
  4. package/gui/dist/assets/index-Cd6_PBKn.css +1 -0
  5. package/gui/dist/assets/index-ChZQsmBY.js +70 -0
  6. package/gui/dist/index.html +2 -2
  7. package/gui/dist/provider-icons/alibaba-color.svg +1 -1
  8. package/gui/dist/provider-icons/antigravity-color.svg +1 -1
  9. package/gui/dist/provider-icons/claude-color.svg +1 -1
  10. package/gui/dist/provider-icons/cline-color.svg +16 -0
  11. package/gui/dist/provider-icons/cloudflare-ai-gateway-color.svg +1 -1
  12. package/gui/dist/provider-icons/copilot-color.svg +1 -1
  13. package/gui/dist/provider-icons/cursor-color.svg +1 -1
  14. package/gui/dist/provider-icons/deepseek-color.svg +1 -1
  15. package/gui/dist/provider-icons/firepass-color.svg +1 -1
  16. package/gui/dist/provider-icons/fireworks-color.svg +1 -1
  17. package/gui/dist/provider-icons/gemini-color.svg +1 -1
  18. package/gui/dist/provider-icons/github-copilot-color.svg +1 -1
  19. package/gui/dist/provider-icons/gitlab-duo-color.svg +1 -1
  20. package/gui/dist/provider-icons/grok.svg +1 -1
  21. package/gui/dist/provider-icons/groq-color.svg +1 -1
  22. package/gui/dist/provider-icons/huggingface-color.svg +1 -1
  23. package/gui/dist/provider-icons/kimi-color.svg +1 -1
  24. package/gui/dist/provider-icons/kiro-color.svg +2 -2
  25. package/gui/dist/provider-icons/lm-studio-color.svg +1 -1
  26. package/gui/dist/provider-icons/mistral-color.svg +1 -1
  27. package/gui/dist/provider-icons/moonshot-color.svg +1 -1
  28. package/gui/dist/provider-icons/nvidia-color.svg +1 -1
  29. package/gui/dist/provider-icons/ollama-color.svg +1 -1
  30. package/gui/dist/provider-icons/openai.svg +1 -1
  31. package/gui/dist/provider-icons/opencode.svg +2 -1
  32. package/gui/dist/provider-icons/openrouter-color.svg +1 -1
  33. package/gui/dist/provider-icons/pi.svg +2 -2
  34. package/gui/dist/provider-icons/qianfan-color.svg +1 -1
  35. package/gui/dist/provider-icons/qwen-portal-color.svg +1 -1
  36. package/gui/dist/provider-icons/vercel-ai-gateway-color.svg +1 -1
  37. package/gui/dist/provider-icons/vllm-color.svg +1 -1
  38. package/gui/dist/provider-icons/xiaomi-color.svg +1 -1
  39. package/package.json +8 -4
  40. package/src/adapters/anthropic.ts +208 -14
  41. package/src/adapters/base.ts +16 -5
  42. package/src/adapters/cursor/effort-map.ts +3 -2
  43. package/src/adapters/cursor/framing.ts +39 -0
  44. package/src/adapters/cursor/live-transport.ts +105 -95
  45. package/src/adapters/cursor/native-exec.ts +32 -6
  46. package/src/adapters/cursor/protobuf-request.ts +20 -15
  47. package/src/adapters/cursor/request-builder.ts +21 -7
  48. package/src/adapters/cursor/types.ts +7 -0
  49. package/src/adapters/google-antigravity-replay.ts +237 -21
  50. package/src/adapters/google-truncation.ts +11 -0
  51. package/src/adapters/google.ts +50 -9
  52. package/src/adapters/identity.ts +39 -6
  53. package/src/adapters/kiro-errors.ts +11 -0
  54. package/src/adapters/kiro-events.ts +19 -1
  55. package/src/adapters/kiro-thinking.ts +10 -2
  56. package/src/adapters/kiro-tools.ts +10 -1
  57. package/src/adapters/kiro.ts +37 -11
  58. package/src/adapters/openai-chat.ts +284 -83
  59. package/src/adapters/openai-responses.ts +182 -24
  60. package/src/bridge.ts +177 -7
  61. package/src/chat/outbound.ts +78 -23
  62. package/src/claude/agents-inject.ts +27 -5
  63. package/src/claude/inbound.ts +11 -1
  64. package/src/claude/model-info.ts +13 -10
  65. package/src/claude/outbound.ts +17 -0
  66. package/src/cli/account-api.ts +24 -0
  67. package/src/cli/account-auth.ts +31 -6
  68. package/src/cli/account-main.ts +317 -0
  69. package/src/cli/account.ts +5 -0
  70. package/src/cli/claude.ts +2 -1
  71. package/src/cli/doctor.ts +93 -22
  72. package/src/cli/export-command.ts +26 -12
  73. package/src/cli/help.ts +8 -6
  74. package/src/cli/index.ts +56 -22
  75. package/src/cli/integrations.ts +84 -1
  76. package/src/cli/observe.ts +54 -1
  77. package/src/cli/opencode.ts +2 -1
  78. package/src/cli/provider-runtime.ts +18 -1
  79. package/src/cli/route-policy.ts +92 -0
  80. package/src/cli/runtime-api.ts +6 -3
  81. package/src/cli/star-prompt.ts +71 -15
  82. package/src/cli/status.ts +1 -1
  83. package/src/cli/v2.ts +36 -9
  84. package/src/clients/config-export.ts +687 -10
  85. package/src/codex/account-lifecycle.ts +30 -5
  86. package/src/codex/account-usability.ts +22 -2
  87. package/src/codex/admission.ts +255 -0
  88. package/src/codex/auth-api.ts +427 -140
  89. package/src/codex/auth-context.ts +155 -30
  90. package/src/codex/autostart-health.ts +8 -1
  91. package/src/codex/catalog/account-models.ts +62 -0
  92. package/src/codex/catalog/aggregation.ts +14 -1
  93. package/src/codex/catalog/bundled.ts +282 -32
  94. package/src/codex/catalog/filesystem-evidence.ts +302 -0
  95. package/src/codex/catalog/metadata.ts +51 -6
  96. package/src/codex/catalog/parsing.ts +6 -3
  97. package/src/codex/catalog/provider-fetch.ts +576 -41
  98. package/src/codex/catalog/sync.ts +505 -66
  99. package/src/codex/catalog-admission.ts +197 -0
  100. package/src/codex/catalog-write-serialization.ts +241 -0
  101. package/src/codex/catalog.ts +2 -1
  102. package/src/codex/codex-write-lock.ts +372 -0
  103. package/src/codex/convergence-types.ts +593 -0
  104. package/src/codex/convergence.ts +441 -0
  105. package/src/codex/desired-state.ts +177 -0
  106. package/src/codex/features.ts +52 -8
  107. package/src/codex/generation.ts +202 -0
  108. package/src/codex/history-job.ts +257 -0
  109. package/src/codex/history-lock.ts +241 -0
  110. package/src/codex/history-migration-guardian.ts +18 -5
  111. package/src/codex/history-provider.ts +9 -2
  112. package/src/codex/history-transition.ts +105 -0
  113. package/src/codex/history-worker.ts +176 -0
  114. package/src/codex/inject-coordination.ts +245 -0
  115. package/src/codex/inject.ts +605 -124
  116. package/src/codex/integration-record.ts +266 -0
  117. package/src/codex/internal/catalog-writer.ts +203 -0
  118. package/src/codex/internal/history-writer.ts +80 -0
  119. package/src/codex/journal.ts +10 -1
  120. package/src/codex/main-account-cache.ts +24 -0
  121. package/src/codex/management-convergence.ts +114 -0
  122. package/src/codex/native-main-admission.ts +47 -0
  123. package/src/codex/native-main-auth-temp.ts +187 -0
  124. package/src/codex/native-main-claim.ts +167 -0
  125. package/src/codex/native-main-lock-file.ts +158 -0
  126. package/src/codex/native-main-owner.ts +315 -0
  127. package/src/codex/native-profile-api.ts +247 -0
  128. package/src/codex/native-profile-manager.ts +1512 -0
  129. package/src/codex/native-profile-processes.ts +121 -0
  130. package/src/codex/native-profile-recovery.ts +99 -0
  131. package/src/codex/native-profile-stage-store.ts +387 -0
  132. package/src/codex/native-profile-startup.ts +340 -0
  133. package/src/codex/native-profile-store.ts +855 -0
  134. package/src/codex/native-profile-types.ts +120 -0
  135. package/src/codex/native-residue.ts +557 -0
  136. package/src/codex/project-config-warnings.ts +18 -4
  137. package/src/codex/prompt-journal.ts +311 -0
  138. package/src/codex/prompt-layers.ts +967 -0
  139. package/src/codex/prompt-lock.ts +143 -0
  140. package/src/codex/quota-rejection.ts +224 -0
  141. package/src/codex/quota.ts +86 -3
  142. package/src/codex/routing.ts +299 -62
  143. package/src/codex/runtime.ts +159 -38
  144. package/src/codex/shim.ts +39 -13
  145. package/src/codex/subagent-model-fallback.ts +73 -12
  146. package/src/codex/transition-state.ts +604 -0
  147. package/src/codex/upstream-host-health.ts +70 -0
  148. package/src/codex/user-identity.ts +266 -0
  149. package/src/codex/write-coordination.ts +114 -0
  150. package/src/config.ts +562 -26
  151. package/src/generated/jawcode-model-metadata.ts +2 -2
  152. package/src/grok/inject.ts +15 -4
  153. package/src/grok/inspect.ts +45 -0
  154. package/src/images/loop.ts +113 -20
  155. package/src/integrations/config-io.ts +151 -0
  156. package/src/integrations/journal.ts +315 -0
  157. package/src/integrations/merge.ts +135 -0
  158. package/src/integrations/native/ownership-preflight.ts +165 -0
  159. package/src/integrations/ownership.ts +111 -0
  160. package/src/integrations/registry.ts +101 -0
  161. package/src/integrations/serialize.ts +235 -0
  162. package/src/integrations/state.ts +290 -0
  163. package/src/integrations/store.ts +103 -0
  164. package/src/integrations/writer.ts +492 -0
  165. package/src/lib/bounded-body.ts +46 -8
  166. package/src/lib/bun-runtime.ts +110 -1
  167. package/src/lib/bun-stream-caps.ts +2 -1
  168. package/src/lib/redact.ts +407 -2
  169. package/src/lib/shadow-call.ts +24 -0
  170. package/src/lib/translator-budget.ts +10 -0
  171. package/src/lib/upstream-reachability.ts +91 -0
  172. package/src/lib/upstream-retry.ts +154 -2
  173. package/src/lib/windows-secret-acl.ts +212 -11
  174. package/src/lib/winsw.ts +9 -3
  175. package/src/oauth/index.ts +61 -3
  176. package/src/oauth/key-providers.ts +4 -0
  177. package/src/oauth/kiro.ts +50 -6
  178. package/src/oauth/store.ts +31 -6
  179. package/src/oauth/token-guardian.ts +9 -3
  180. package/src/providers/codex-capacity.ts +288 -0
  181. package/src/providers/derive.ts +33 -1
  182. package/src/providers/free-directory.ts +3 -1
  183. package/src/providers/key-failover.ts +71 -3
  184. package/src/providers/openai-sidecar.ts +64 -4
  185. package/src/providers/openai-virtual-models.ts +1 -0
  186. package/src/providers/quota.ts +334 -26
  187. package/src/providers/registry.ts +284 -16
  188. package/src/providers/xai-transport.ts +11 -4
  189. package/src/responses/compaction.ts +8 -1
  190. package/src/responses/hosted-tool-policy.ts +9 -0
  191. package/src/responses/parser.ts +44 -2
  192. package/src/responses/reasoning-envelope.ts +9 -1
  193. package/src/responses/reasoning-replay-cache.ts +105 -0
  194. package/src/responses/spill-store.ts +45 -8
  195. package/src/responses/state.ts +161 -17
  196. package/src/router.ts +302 -16
  197. package/src/routing/analytics.ts +377 -0
  198. package/src/routing/capability.ts +204 -0
  199. package/src/routing/cost.ts +77 -0
  200. package/src/routing/evaluator.ts +444 -0
  201. package/src/routing/health.ts +401 -0
  202. package/src/routing/history/cursor.ts +43 -0
  203. package/src/routing/history/indexer.ts +590 -0
  204. package/src/routing/history/schema.ts +72 -0
  205. package/src/routing/profile.ts +423 -0
  206. package/src/routing/quota.ts +124 -0
  207. package/src/routing/request-evidence.ts +45 -0
  208. package/src/routing/trace.ts +686 -0
  209. package/src/server/auth-cors.ts +46 -6
  210. package/src/server/chat-completions.ts +28 -13
  211. package/src/server/claude-messages.ts +23 -15
  212. package/src/server/gui-static.ts +39 -10
  213. package/src/server/images.ts +10 -1
  214. package/src/server/index.ts +238 -52
  215. package/src/server/lifecycle.ts +228 -9
  216. package/src/server/live.ts +14 -3
  217. package/src/server/management/agent-settings-routes.ts +64 -14
  218. package/src/server/management/combo-routes.ts +5 -5
  219. package/src/server/management/config-routes.ts +1 -1
  220. package/src/server/management/context.ts +42 -2
  221. package/src/server/management/integration-routes.ts +538 -0
  222. package/src/server/management/logs-usage-routes.ts +1 -1
  223. package/src/server/management/model-routes.ts +32 -113
  224. package/src/server/management/model-rows.ts +117 -0
  225. package/src/server/management/native-integration-routes.ts +587 -0
  226. package/src/server/management/oauth-account-routes.ts +1 -1
  227. package/src/server/management/provider-routes.ts +218 -117
  228. package/src/server/management/request-history-routes.ts +191 -0
  229. package/src/server/management/routing-analytics-routes.ts +74 -0
  230. package/src/server/management/routing-profile-routes.ts +177 -0
  231. package/src/server/management/shared.ts +2 -2
  232. package/src/server/management/sidebar-routes.ts +47 -31
  233. package/src/server/management/sync-response.ts +69 -0
  234. package/src/server/management/system-restart.ts +276 -24
  235. package/src/server/management/system-routes.ts +4 -0
  236. package/src/server/management-api.ts +84 -9
  237. package/src/server/management-auth.ts +43 -5
  238. package/src/server/relay-eager.ts +82 -42
  239. package/src/server/relay.ts +120 -6
  240. package/src/server/request-log.ts +26 -6
  241. package/src/server/responses/collaboration.ts +63 -8
  242. package/src/server/responses/compact.ts +272 -41
  243. package/src/server/responses/core.ts +730 -132
  244. package/src/server/responses/fetch-helpers.ts +15 -1
  245. package/src/server/responses-item-id-repair.ts +32 -3
  246. package/src/server/responses-json-events.ts +52 -0
  247. package/src/server/responses-snapshot-repair.ts +621 -0
  248. package/src/server/search.ts +51 -6
  249. package/src/server/sse-payload-rewrite.ts +89 -12
  250. package/src/server/startup-health-cache.ts +7 -1
  251. package/src/server/ws-bridge.ts +11 -17
  252. package/src/service-manager-probe.ts +297 -0
  253. package/src/service.ts +222 -32
  254. package/src/tray/windows-tray.ps1 +9 -0
  255. package/src/tray/windows.ts +15 -7
  256. package/src/types.ts +194 -14
  257. package/src/update/index.ts +13 -13
  258. package/src/update/job.ts +24 -21
  259. package/src/update/notify.ts +7 -3
  260. package/src/usage/cost.ts +0 -0
  261. package/src/usage/expected-prices.ts +129 -10
  262. package/src/usage/log.ts +50 -15
  263. package/src/usage/summary.ts +4 -4
  264. package/src/vision/index.ts +6 -1
  265. package/src/web-search/loop.ts +161 -34
  266. package/gui/dist/assets/index-OY43ubAq.css +0 -1
  267. package/gui/dist/assets/index-YwNnKZcL.js +0 -67
  268. package/gui/dist/provider-icons/antigravity.svg +0 -1
  269. package/gui/dist/provider-icons/claude.svg +0 -1
  270. package/gui/dist/provider-icons/copilot.svg +0 -1
  271. package/gui/dist/provider-icons/cursor.svg +0 -2
  272. package/gui/dist/provider-icons/gemini.svg +0 -1
  273. package/gui/dist/provider-icons/grok-color.svg +0 -1
  274. package/gui/dist/provider-icons/kiro.svg +0 -14
@@ -23,39 +23,116 @@
23
23
  */
24
24
  import { spawn } from "node:child_process";
25
25
  import {
26
+ beginShutdownDrain,
26
27
  drainAndShutdown,
27
28
  getActiveTurnCount,
28
29
  getServerListenPort,
29
30
  isDraining,
31
+ isShutdownDraining,
30
32
  markRecyclingForExit,
31
- setDraining,
33
+ stopServerListener,
32
34
  } from "../lifecycle";
33
35
  import { isServiceViable } from "../../service";
34
36
  import { readRuntimePort } from "../../config";
37
+ import { withProcessRuntimeProvenance } from "../../lib/bun-runtime";
38
+ import { findLiveProxy } from "../proxy-liveness";
35
39
 
36
40
  /** Fixed v1 drain window for the memory-card action (not config-driven). */
37
41
  export const MEMORY_DRAIN_RESTART_MS = 60_000;
42
+ // Ordinary pinned-port start can spend 60s reclaiming a Windows ghost listener
43
+ // and another 5s settling it. Keep one polling/scheduler margin beyond that.
44
+ export const REPLACEMENT_READY_TIMEOUT_MS = 70_000;
45
+ export const DEADLINE_LISTENER_STOP_TIMEOUT_MS = 5_000;
46
+ const REPLACEMENT_READY_POLL_MS = 150;
47
+
48
+ export interface ReplacementReadinessIo {
49
+ findLive?: typeof findLiveProxy;
50
+ now?: () => number;
51
+ sleep?: (ms: number) => Promise<void>;
52
+ }
38
53
 
39
54
  export interface SystemRestartIo {
40
55
  drainAndShutdown?: typeof drainAndShutdown;
41
56
  /** True when a background service can actually respawn this process after exit(1). */
42
57
  isServiceViable?: () => boolean;
43
58
  isSupervisedServiceChild?: () => boolean;
44
- /** Must resolve only after the replacement process has actually started. */
45
- spawnStart?: (port?: number) => void | Promise<void>;
59
+ /** Ordinary start; deadline handoff may defer health until parent exit releases OS locks. */
60
+ spawnStart?: (port?: number, waitForHealthBeforeParentExit?: boolean) => void | Promise<void>;
61
+ /** Idempotent listener close; must settle before an ordinary start is spawned. */
62
+ stopListener?: () => void | Promise<void>;
46
63
  markRecycling?: () => void;
47
64
  exitProcess?: (code: number) => void;
48
65
  schedule?: (fn: () => void | Promise<void>, ms: number) => void;
66
+ scheduleDeadline?: (fn: () => void, ms: number) => () => void;
49
67
  isDraining?: () => boolean;
68
+ isShutdownDraining?: () => boolean;
69
+ beginShutdownDrain?: () => boolean;
50
70
  setDraining?: (value: boolean) => void;
51
71
  getActiveTurnCount?: () => number;
52
72
  listenPort?: () => number | undefined;
73
+ now?: () => number;
53
74
  }
54
75
 
55
76
  let restartIo: SystemRestartIo = {};
56
77
  /** Prevents double-scheduling in the 200ms window before drainAndShutdown sets draining. */
57
78
  let restartAccepted = false;
58
79
 
80
+ type RestartDrainOutcome = "completed" | "rejected" | "deadline";
81
+ type BoundedSettlementOutcome = "completed" | "rejected" | "deadline";
82
+
83
+ function waitForRestartDrain(
84
+ drainPromise: Promise<void>,
85
+ deadlineMs: number,
86
+ now: () => number,
87
+ scheduleDeadline: NonNullable<SystemRestartIo["scheduleDeadline"]>,
88
+ ): Promise<RestartDrainOutcome> {
89
+ const remainingMs = Math.max(0, deadlineMs - now());
90
+ if (remainingMs === 0) {
91
+ // Observe any late rejection even though orchestration is already terminal.
92
+ void drainPromise.catch(() => {});
93
+ return Promise.resolve("deadline");
94
+ }
95
+ return new Promise<RestartDrainOutcome>((resolve) => {
96
+ let settled = false;
97
+ let cancelDeadline: (() => void) | undefined;
98
+ const finish = (outcome: RestartDrainOutcome) => {
99
+ if (settled) return;
100
+ settled = true;
101
+ cancelDeadline?.();
102
+ resolve(outcome);
103
+ };
104
+ cancelDeadline = scheduleDeadline(() => finish("deadline"), remainingMs);
105
+ if (settled) cancelDeadline();
106
+ void drainPromise.then(
107
+ () => finish("completed"),
108
+ () => finish("rejected"),
109
+ );
110
+ });
111
+ }
112
+
113
+ function waitForBoundedSettlement(
114
+ promise: Promise<void>,
115
+ timeoutMs: number,
116
+ scheduleDeadline: NonNullable<SystemRestartIo["scheduleDeadline"]>,
117
+ ): Promise<BoundedSettlementOutcome> {
118
+ return new Promise<BoundedSettlementOutcome>((resolve) => {
119
+ let settled = false;
120
+ let cancelDeadline: (() => void) | undefined;
121
+ const finish = (outcome: BoundedSettlementOutcome) => {
122
+ if (settled) return;
123
+ settled = true;
124
+ cancelDeadline?.();
125
+ resolve(outcome);
126
+ };
127
+ cancelDeadline = scheduleDeadline(() => finish("deadline"), timeoutMs);
128
+ if (settled) cancelDeadline();
129
+ void promise.then(
130
+ () => finish("completed"),
131
+ () => finish("rejected"),
132
+ );
133
+ });
134
+ }
135
+
59
136
  /** Test seam — reset between tests. */
60
137
  export function setSystemRestartIoForTests(io: SystemRestartIo = {}): void {
61
138
  restartIo = io;
@@ -86,10 +163,62 @@ function spawnFailureCode(err: unknown): string {
86
163
  return "spawn_failed";
87
164
  }
88
165
 
89
- function spawnDetachedStart(port?: number): Promise<void> {
166
+ function handoffError(code: string): NodeJS.ErrnoException {
167
+ const error = new Error(code) as NodeJS.ErrnoException;
168
+ error.code = code;
169
+ return error;
170
+ }
171
+
172
+ export async function waitForReplacementReady(
173
+ expectedPid: number | undefined,
174
+ parentPid: number,
175
+ expectedPort: number | undefined,
176
+ io: ReplacementReadinessIo = {},
177
+ ): Promise<boolean> {
178
+ const findLive = io.findLive ?? findLiveProxy;
179
+ const now = io.now ?? Date.now;
180
+ const sleep = io.sleep ?? Bun.sleep;
181
+ const deadline = now() + REPLACEMENT_READY_TIMEOUT_MS;
182
+ while (now() < deadline) {
183
+ try {
184
+ const live = await findLive({
185
+ deadlineAt: deadline,
186
+ nowFn: now,
187
+ sleepFn: sleep,
188
+ });
189
+ // A probe that began within budget can still return after it. Never accept
190
+ // delayed health as proof once the shared absolute handoff budget expired.
191
+ if (now() >= deadline) return false;
192
+ if (
193
+ live
194
+ && live.pid !== null
195
+ && live.pid !== parentPid
196
+ && (expectedPid === undefined || live.pid === expectedPid)
197
+ && (expectedPort === undefined || live.port === expectedPort)
198
+ ) {
199
+ return true;
200
+ }
201
+ } catch {
202
+ // A not-yet-bound replacement is indistinguishable from a transient
203
+ // liveness failure here; keep polling inside the one bounded window.
204
+ }
205
+ const remainingMs = deadline - now();
206
+ if (remainingMs <= 0) break;
207
+ await sleep(Math.min(REPLACEMENT_READY_POLL_MS, remainingMs));
208
+ }
209
+ return false;
210
+ }
211
+
212
+ function spawnDetachedStart(
213
+ port?: number,
214
+ waitForHealthBeforeParentExit = true,
215
+ ): Promise<void> {
90
216
  const args = [process.argv[1], "start"];
91
- if (typeof port === "number" && Number.isFinite(port) && port > 0 && port <= 65535) {
92
- args.push("--port", String(Math.trunc(port)));
217
+ const expectedPort = typeof port === "number" && Number.isFinite(port) && port > 0 && port <= 65535
218
+ ? Math.trunc(port)
219
+ : undefined;
220
+ if (expectedPort !== undefined) {
221
+ args.push("--port", String(expectedPort));
93
222
  }
94
223
  return new Promise<void>((resolve, reject) => {
95
224
  let child: ReturnType<typeof spawn>;
@@ -98,30 +227,113 @@ function spawnDetachedStart(port?: number): Promise<void> {
98
227
  detached: true,
99
228
  stdio: "ignore",
100
229
  windowsHide: true,
101
- env: { ...process.env, OCX_SERVICE: "1" },
230
+ env: withProcessRuntimeProvenance({ ...process.env, OCX_SERVICE: "1" }),
102
231
  });
103
232
  } catch (err) {
104
233
  reject(err);
105
234
  return;
106
235
  }
107
236
  let settled = false;
108
- const finish = (fn: () => void) => {
237
+ const cleanup = () => {
238
+ child.off("error", onError);
239
+ child.off("exit", onExit);
240
+ child.off("spawn", onSpawn);
241
+ };
242
+ const finish = (error?: unknown) => {
109
243
  if (settled) return;
110
244
  settled = true;
111
- fn();
245
+ cleanup();
246
+ if (error !== undefined) {
247
+ if (child.exitCode === null && child.signalCode === null) {
248
+ try { child.kill(); } catch { /* best-effort failed-start cleanup */ }
249
+ }
250
+ try { child.unref(); } catch { /* best-effort */ }
251
+ reject(error);
252
+ return;
253
+ }
254
+ child.unref();
255
+ resolve();
112
256
  };
113
- child.once("error", (err) => {
114
- finish(() => reject(err));
115
- });
116
- child.once("spawn", () => {
117
- finish(() => {
118
- child.unref();
119
- resolve();
120
- });
121
- });
257
+ const onError = (err: Error) => { finish(err); };
258
+ const onExit = () => { finish(handoffError("child_exit")); };
259
+ const onSpawn = () => {
260
+ if (!waitForHealthBeforeParentExit) {
261
+ // A deadline may have been caused by native-main ownership cleanup.
262
+ // Let the ordinary child survive parent exit, which releases those OS locks.
263
+ finish();
264
+ return;
265
+ }
266
+ void waitForReplacementReady(child.pid, process.pid, expectedPort).then(
267
+ ready => {
268
+ if (!ready) {
269
+ console.warn(
270
+ "Drain-and-restart replacement is still alive after the readiness window; allowing it to continue after parent exit",
271
+ );
272
+ }
273
+ // Never kill a live ordinary start at its valid reclaim boundary. Parent
274
+ // exit is the final resource release the child may still be waiting for.
275
+ finish();
276
+ },
277
+ err => finish(err),
278
+ );
279
+ };
280
+ child.once("error", onError);
281
+ child.once("exit", onExit);
282
+ child.once("spawn", onSpawn);
122
283
  });
123
284
  }
124
285
 
286
+ async function completeDeferredParentExitHandoff(
287
+ io: SystemRestartIo,
288
+ exitProcess: (code: number) => void,
289
+ port: number | undefined,
290
+ phase: "deadline" | "listener-stop fallback",
291
+ ): Promise<void> {
292
+ try {
293
+ await (io.spawnStart ?? spawnDetachedStart)(port, false);
294
+ } catch (err) {
295
+ console.warn(
296
+ `Drain-and-restart ${phase} spawn failed (${spawnFailureCode(err)}); exiting without replacement`,
297
+ );
298
+ delete process.env.OCX_SERVICE;
299
+ exitProcess(1);
300
+ return;
301
+ }
302
+ (io.markRecycling ?? markRecyclingForExit)();
303
+ exitProcess(0);
304
+ }
305
+
306
+ async function completeDeadlineRestartHandoff(
307
+ io: SystemRestartIo,
308
+ exitProcess: (code: number) => void,
309
+ port: number | undefined,
310
+ scheduleDeadline: NonNullable<SystemRestartIo["scheduleDeadline"]>,
311
+ ): Promise<void> {
312
+ const supervised = (io.isSupervisedServiceChild ?? (() => isSupervisedServiceChild(io)))();
313
+ if (supervised) {
314
+ // Failure-only supervisors ignore exit(0); intentional non-zero triggers respawn.
315
+ exitProcess(1);
316
+ return;
317
+ }
318
+
319
+ const stopPromise = Promise.resolve().then(
320
+ () => (io.stopListener ?? (() => stopServerListener()))(),
321
+ );
322
+ const stopOutcome = await waitForBoundedSettlement(
323
+ stopPromise,
324
+ DEADLINE_LISTENER_STOP_TIMEOUT_MS,
325
+ scheduleDeadline,
326
+ );
327
+ if (stopOutcome === "rejected") {
328
+ console.warn("Drain-and-restart deadline listener stop failed; continuing parent-exit handoff");
329
+ } else if (stopOutcome === "deadline") {
330
+ console.warn("Drain-and-restart deadline listener stop timed out; continuing parent-exit handoff");
331
+ }
332
+ // The ordinary child must survive parent exit: a failed/pending socket close
333
+ // or overdue cleanup is completed by process teardown, without a hidden mode.
334
+ await completeDeferredParentExitHandoff(io, exitProcess, port, "deadline");
335
+ }
336
+
125
337
  /**
126
338
  * Accept a drain-and-restart request. Returns immediately; the drain +
127
339
  * respawn runs on a short timer so the HTTP response can flush first.
@@ -133,27 +345,67 @@ export function acceptSystemRestart(io: SystemRestartIo = restartIo): {
133
345
  activeTurnCount: number;
134
346
  drainTimeoutMs: number;
135
347
  } {
136
- const alreadyDraining = restartAccepted || (io.isDraining ?? isDraining)();
348
+ const shutdownActive = io.isShutdownDraining
349
+ ?? io.isDraining
350
+ ?? isShutdownDraining;
351
+ const alreadyDraining = restartAccepted || shutdownActive();
137
352
  const activeTurnCount = (io.getActiveTurnCount ?? getActiveTurnCount)();
138
353
  const schedule = io.schedule ?? ((fn, ms) => { setTimeout(() => { void fn(); }, ms); });
139
354
 
140
355
  if (!alreadyDraining) {
141
356
  restartAccepted = true;
357
+ const now = io.now ?? Date.now;
358
+ const restartDeadlineMs = now() + MEMORY_DRAIN_RESTART_MS;
142
359
  // Reject new data-plane traffic immediately (503), before the 200ms response-flush delay.
143
- (io.setDraining ?? setDraining)(true);
360
+ if (io.beginShutdownDrain) io.beginShutdownDrain();
361
+ else if (io.setDraining) io.setDraining(true);
362
+ else beginShutdownDrain();
144
363
  schedule(async () => {
364
+ // Preserve the live binding before drainAndShutdown (or its deadline race)
365
+ // closes the listener and makes both the server ref and runtime metadata stale.
366
+ const restartPort = (io.listenPort ?? resolveListenPort)();
145
367
  const drain = io.drainAndShutdown ?? drainAndShutdown;
146
- await drain(undefined, MEMORY_DRAIN_RESTART_MS);
368
+ const remainingMs = Math.max(0, restartDeadlineMs - now());
369
+ const drainPromise = Promise.resolve().then(() => drain(undefined, remainingMs));
370
+ const scheduleDeadline = io.scheduleDeadline ?? ((fn, ms) => {
371
+ const timer = setTimeout(fn, ms);
372
+ return () => clearTimeout(timer);
373
+ });
374
+ const drainOutcome = await waitForRestartDrain(drainPromise, restartDeadlineMs, now, scheduleDeadline);
375
+ const exitProcess = io.exitProcess ?? ((code: number) => { process.exit(code); });
376
+ if (drainOutcome === "deadline") {
377
+ console.warn("Drain-and-restart deadline expired; forcing terminal restart handoff");
378
+ await completeDeadlineRestartHandoff(io, exitProcess, restartPort, scheduleDeadline);
379
+ return;
380
+ }
381
+ if (drainOutcome === "rejected") {
382
+ // drainAndShutdown stops the listener in finally. Even if ancillary cleanup
383
+ // rejects, an accepted restart must still reach replacement or terminal exit.
384
+ console.warn("Drain-and-restart cleanup failed; continuing terminal restart handoff");
385
+ }
147
386
  const supervised = (io.isSupervisedServiceChild ?? (() => isSupervisedServiceChild(io)))();
148
387
  if (supervised) {
149
388
  // Failure-only supervisors ignore exit(0); intentional non-zero triggers respawn.
150
389
  (io.exitProcess ?? ((code: number) => { process.exit(code); }))(1);
151
390
  return;
152
391
  }
153
- const port = (io.listenPort ?? resolveListenPort)();
154
- const exitProcess = io.exitProcess ?? ((code: number) => { process.exit(code); });
155
392
  try {
156
- await (io.spawnStart ?? spawnDetachedStart)(port);
393
+ await (io.stopListener ?? (() => stopServerListener()))();
394
+ } catch {
395
+ console.warn("Drain-and-restart listener stop failed; continuing parent-exit handoff");
396
+ await completeDeferredParentExitHandoff(
397
+ io,
398
+ exitProcess,
399
+ restartPort,
400
+ "listener-stop fallback",
401
+ );
402
+ return;
403
+ }
404
+ try {
405
+ // A rejected drain has uncertain cleanup ownership, so it uses the same
406
+ // parent-exit handoff as a deadline. Only a fully completed drain waits
407
+ // for replacement health in the old process.
408
+ await (io.spawnStart ?? spawnDetachedStart)(restartPort, drainOutcome === "completed");
157
409
  } catch (err) {
158
410
  console.warn(
159
411
  `⚠️ Drain-and-restart spawn failed (${spawnFailureCode(err)}); exiting without replacement`,
@@ -22,6 +22,7 @@
22
22
  * dashboard drain-and-restart confirm UX — never request bodies or IDs.
23
23
  */
24
24
  import { selectEagerPath } from "../../lib/bun-stream-caps";
25
+ import { reportedBunRuntimeSource } from "../../lib/bun-runtime";
25
26
  import { getActiveTurnCount, isDraining } from "../lifecycle";
26
27
  import { getActiveMemoryWatchdog, observedMemoryCounter } from "../memory-watchdog";
27
28
  import { responseStateMetrics } from "../../responses/state";
@@ -78,6 +79,9 @@ export async function handleSystemRoutes(ctx: ManagementContext): Promise<Respon
78
79
  pid: process.pid,
79
80
  bunVersion: Bun.version,
80
81
  bunRevision: Bun.revision,
82
+ // Recorded at launch, not resolved now: absent means "this service predates the
83
+ // marker", which callers must report as unknown rather than guess.
84
+ bunRuntimeSource: reportedBunRuntimeSource(),
81
85
  platform: process.platform,
82
86
  uptimeSeconds: process.uptime(),
83
87
  rss: usage.rss,
@@ -59,6 +59,9 @@ import { applySystemEnvToggle } from "./system-env";
59
59
  import type { ManagementApiDeps } from "./management/context";
60
60
  import { handleConfigRoutes } from "./management/config-routes";
61
61
  import { handleLogsUsageRoutes } from "./management/logs-usage-routes";
62
+ import { handleRequestHistoryRoutes } from "./management/request-history-routes";
63
+ import { handleRoutingAnalyticsRoutes } from "./management/routing-analytics-routes";
64
+ import { handleRoutingProfileRoutes } from "./management/routing-profile-routes";
62
65
  import { handleProviderRoutes } from "./management/provider-routes";
63
66
  import { handleModelRoutes } from "./management/model-routes";
64
67
  import { handleAgentSettingsRoutes } from "./management/agent-settings-routes";
@@ -66,10 +69,14 @@ import { handleOauthAccountRoutes } from "./management/oauth-account-routes";
66
69
  import { handleComboRoutes } from "./management/combo-routes";
67
70
  import { handleSystemRoutes } from "./management/system-routes";
68
71
  import { handleSidebarRoutes } from "./management/sidebar-routes";
72
+ import { handleIntegrationRoutes } from "./management/integration-routes";
73
+ import { handleNativeIntegrationRoutes } from "./management/native-integration-routes";
69
74
  import type { ManagementContext } from "./management/context";
75
+ import type { ManagementPrincipal } from "./management-auth";
70
76
  export type { ManagementApiDeps } from "./management/context";
71
77
  import { fetchAllModels } from "./management/shared";
72
78
  import { CatalogGatherBusyError } from "../codex/catalog/provider-fetch";
79
+ import type { CatalogDisposition, ConvergeCodex } from "../codex/convergence-types";
73
80
  import { managementBodyTooLargeResponse } from "./management/body";
74
81
 
75
82
  // installed npm version instead of a stale hardcode.
@@ -81,7 +88,40 @@ export const VERSION = (() => {
81
88
  }
82
89
  })();
83
90
 
84
- export async function handleManagementAPI(req: Request, url: URL, config: OcxConfig, deps: ManagementApiDeps = {}): Promise<Response | null> {
91
+ function isCatalogDisposition(value: unknown): value is CatalogDisposition {
92
+ if (!value || typeof value !== "object" || !("status" in value)) return false;
93
+ const disposition = value as Record<string, unknown>;
94
+ if (disposition.status === "committed") {
95
+ return typeof disposition.changed === "boolean"
96
+ && typeof disposition.degraded === "boolean"
97
+ && Array.isArray(disposition.notices)
98
+ && disposition.notices.every(notice => notice === "provider-auth" || notice === "provider-network" || notice === "fallback");
99
+ }
100
+ if (disposition.status === "skipped") {
101
+ return ["not-requested", "catalog-unavailable", "busy", "stale", "refused"].includes(String(disposition.reason))
102
+ && typeof disposition.retryable === "boolean";
103
+ }
104
+ if (disposition.status === "failed") {
105
+ return ["provider-auth", "provider-network", "disk"].includes(String(disposition.reason))
106
+ && (disposition.phase === "gather" || disposition.phase === "commit")
107
+ && typeof disposition.retryable === "boolean"
108
+ && typeof disposition.partialWrite === "boolean";
109
+ }
110
+ return false;
111
+ }
112
+
113
+ const managementConvergenceBindings = new WeakMap<object, Readonly<{
114
+ factory: (config: Readonly<OcxConfig>) => ConvergeCodex;
115
+ converge: ConvergeCodex;
116
+ }>>();
117
+
118
+ export async function handleManagementAPI(
119
+ req: Request,
120
+ url: URL,
121
+ config: OcxConfig,
122
+ deps: ManagementApiDeps = {},
123
+ principal?: ManagementPrincipal,
124
+ ): Promise<Response | null> {
85
125
  if (!isAllowedManagementOrigin(req, config)) {
86
126
  return jsonResponse({ error: "cross-origin request blocked" }, 403, req, config);
87
127
  }
@@ -93,13 +133,38 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
93
133
  return jsonResponse({ error: "request body too large" }, 413, req, config);
94
134
  }
95
135
  }
96
- async function refreshCodexCatalogBestEffort(): Promise<void> {
97
- if (deps.refreshCodexCatalog) return deps.refreshCodexCatalog();
136
+ async function convergeCodexCatalog(): Promise<CatalogDisposition> {
137
+ let convergenceInvoked = false;
138
+ let managementConvergeCodex: ConvergeCodex | undefined;
98
139
  try {
99
- const { refreshCodexModelCatalog } = await import("../codex/refresh");
100
- await refreshCodexModelCatalog(config);
140
+ if (!managementConvergeCodex) {
141
+ const factory = deps.createManagementConvergeCodex
142
+ ?? (await import("../codex/management-convergence")).createManagementConvergeCodex;
143
+ if (typeof factory !== "function") throw new TypeError("Catalog convergence factory is unavailable.");
144
+ let binding = managementConvergenceBindings.get(config);
145
+ if (!binding || binding.factory !== factory) {
146
+ const created = factory(config);
147
+ if (typeof created !== "function") throw new TypeError("Catalog convergence factory returned no function.");
148
+ binding = { factory, converge: created };
149
+ managementConvergenceBindings.set(config, binding);
150
+ }
151
+ managementConvergeCodex = binding.converge;
152
+ }
153
+ const { createCatalogConvergeRequest } = await import("../codex/catalog-admission");
154
+ convergenceInvoked = true;
155
+ const outcome = await managementConvergeCodex(createCatalogConvergeRequest({ deadlineMs: 1_000 }));
156
+ if (!outcome || outcome.kind !== "catalog-only" || !isCatalogDisposition(outcome.catalogRefresh)) {
157
+ throw new TypeError("Catalog convergence returned an invalid outcome.");
158
+ }
159
+ return outcome.catalogRefresh;
101
160
  } catch {
102
- /* catalog absent */
161
+ return {
162
+ status: "failed",
163
+ reason: "disk",
164
+ phase: convergenceInvoked ? "commit" : "gather",
165
+ retryable: false,
166
+ partialWrite: convergenceInvoked,
167
+ };
103
168
  }
104
169
  }
105
170
 
@@ -124,13 +189,18 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
124
189
  }
125
190
  } catch { /* best-effort */ }
126
191
  }
127
- const ctx: ManagementContext = { req, url, config, deps, refreshCodexCatalogBestEffort, syncClaudeAgentDefsBestEffort };
192
+ const ctx: ManagementContext = { req, url, config, deps, principal, convergeCodexCatalog, syncClaudeAgentDefsBestEffort };
128
193
  let routed: Response | null;
129
194
  try {
130
195
  routed = (await handleConfigRoutes(ctx))
131
196
  ?? (await handleLogsUsageRoutes(ctx))
197
+ ?? (await handleRequestHistoryRoutes(ctx))
198
+ ?? (await handleRoutingAnalyticsRoutes(ctx))
199
+ ?? (await handleRoutingProfileRoutes(ctx))
132
200
  ?? (await handleProviderRoutes(ctx))
133
201
  ?? (await handleModelRoutes(ctx))
202
+ ?? (await handleIntegrationRoutes(ctx))
203
+ ?? (await handleNativeIntegrationRoutes(ctx))
134
204
  ?? (await handleAgentSettingsRoutes(ctx))
135
205
  ?? (await handleOauthAccountRoutes(ctx))
136
206
  ?? (await handleComboRoutes(ctx))
@@ -154,7 +224,7 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
154
224
  if (routed) return routed;
155
225
 
156
226
  if (url.pathname === "/api/stop" && req.method === "POST") {
157
- const { restoreNativeCodex } = await import("../codex/inject");
227
+ const { restoreNativeCodexAsync } = await import("../codex/inject");
158
228
  const { stopServiceIfInstalled, isServiceOwnershipError } = await import("../service");
159
229
  try {
160
230
  stopServiceIfInstalled();
@@ -167,7 +237,7 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
167
237
  }
168
238
  throw err;
169
239
  }
170
- const restore = restoreNativeCodex();
240
+ const restore = await restoreNativeCodexAsync();
171
241
  // Both managed configs come down together on an explicit teardown. The daemon's own
172
242
  // syncCleanup skips this when OCX_SERVICE is set (so a crash/respawn keeps the fence),
173
243
  // which is exactly why an intentional stop has to do it here.
@@ -183,6 +253,11 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
183
253
  : { success: false, message: `Proxy stopping, but native Codex restore failed: ${restore.message}. Run \`ocx restore\`.${grokNote}` });
184
254
  }
185
255
 
256
+ if (url.pathname.startsWith("/api/native-main-profiles")) {
257
+ const { handleNativeProfileAPI } = await import("../codex/native-profile-api");
258
+ return handleNativeProfileAPI(req, url, config, deps.nativeProfileApi);
259
+ }
260
+
186
261
  if (url.pathname.startsWith("/api/codex-auth/")) {
187
262
  const { handleCodexAuthAPI } = await import("../codex/auth-api");
188
263
  const { ConfigMutationLockError } = await import("../config");
@@ -13,7 +13,7 @@ import {
13
13
  } from "node:fs";
14
14
  import { dirname, join } from "node:path";
15
15
  import { adminApiTokenFilePath } from "../lib/admin-secrets";
16
- import { forgetHardenedSecretPath, hardenSecretDir, hardenSecretPath } from "../lib/windows-secret-acl";
16
+ import { forgetEphemeralSecretPath, forgetHardenedSecretPath, hardenSecretDir, hardenSecretPath } from "../lib/windows-secret-acl";
17
17
  import type { OcxConfig } from "../types";
18
18
  import {
19
19
  isAllowedManagementOrigin,
@@ -95,12 +95,17 @@ function readExistingToken(path: string): string {
95
95
  export function removeManagementTokenPathBestEffort(
96
96
  path: string,
97
97
  remove: (path: string) => void = unlinkSync,
98
+ options?: { ephemeral?: boolean },
98
99
  ): void {
100
+ // Temps get the full ephemeral release (success + both timeout namespaces);
101
+ // stable token paths drop only the success memo — destination-keyed timeout
102
+ // memos are intentional anti-restall state.
103
+ const forget = options?.ephemeral ? forgetEphemeralSecretPath : forgetHardenedSecretPath;
99
104
  try {
100
105
  remove(path);
101
- forgetHardenedSecretPath(path);
106
+ forget(path);
102
107
  } catch (error) {
103
- if ((error as NodeJS.ErrnoException).code === "ENOENT") forgetHardenedSecretPath(path);
108
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") forget(path);
104
109
  /* other failures retain fail-closed state for the caller */
105
110
  }
106
111
  }
@@ -120,7 +125,8 @@ function createTokenFile(path: string): string {
120
125
  chmodSync(temporary, 0o600);
121
126
  let temporaryHardened: { ok: boolean };
122
127
  try {
123
- temporaryHardened = hardenSecretPath(temporary, { required: true });
128
+ // Destination-keyed timeout memo (the final token path), not the temp.
129
+ temporaryHardened = hardenSecretPath(temporary, { required: true, timeoutMemoKey: path });
124
130
  } catch {
125
131
  temporaryHardened = { ok: false };
126
132
  }
@@ -155,7 +161,7 @@ function createTokenFile(path: string): string {
155
161
  if (fd !== null) {
156
162
  try { closeSync(fd); } catch { /* best effort */ }
157
163
  }
158
- removeManagementTokenPathBestEffort(temporary);
164
+ removeManagementTokenPathBestEffort(temporary, unlinkSync, { ephemeral: true });
159
165
  }
160
166
  }
161
167
 
@@ -231,6 +237,38 @@ export function issueGuiSession(
231
237
  return { token, ...session };
232
238
  }
233
239
 
240
+ /**
241
+ * Which credential actually authorized a management request.
242
+ *
243
+ * `admin-token` is the raw token from disk/env: anything running as the user can
244
+ * read it, including a coding agent. `gui-session` is a session token this process
245
+ * minted for a browser, and it only authorizes a mutation after the origin and the
246
+ * per-session CSRF token match. Consent-bearing routes must key off this value
247
+ * rather than off request headers, which the token holder can forge freely.
248
+ */
249
+ export type ManagementPrincipal = "admin-token" | "gui-session";
250
+
251
+ /**
252
+ * The principal for a request that already passed `requireManagementAuth`. Kept as a
253
+ * separate resolution (rather than a changed return type) so every existing caller
254
+ * keeps its `Response | null` contract; the value is derived from the same session
255
+ * table and the same CSRF comparison the gate uses, so the two cannot disagree.
256
+ */
257
+ export function managementPrincipal(
258
+ req: Request,
259
+ state: ManagementAuthState,
260
+ config?: OcxConfig,
261
+ ): ManagementPrincipal | null {
262
+ if (!state.available) return null;
263
+ const actual = req.headers.get("x-opencodex-api-key")?.trim()
264
+ || req.headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim();
265
+ if (!actual) return null;
266
+ if (equalSecret(actual, state.token)) return "admin-token";
267
+ if (!config) return null;
268
+ removeExpiredSessions(state);
269
+ return state.sessions.has(actual) ? "gui-session" : null;
270
+ }
271
+
234
272
  export function requireManagementAuth(
235
273
  req: Request,
236
274
  state: ManagementAuthState,