@bitkyc08/opencodex 2.10.2 → 2.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/README.md +31 -0
  2. package/bin/ocx.mjs +10 -0
  3. package/gui/dist/assets/index-Bk-PN-70.css +1 -0
  4. package/gui/dist/assets/index-BynIEIV-.js +70 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +4 -2
  7. package/src/adapters/cursor/effort-map.ts +11 -0
  8. package/src/adapters/cursor/live-transport.ts +11 -0
  9. package/src/adapters/cursor/native-exec-fs.ts +9 -6
  10. package/src/adapters/cursor/native-exec.ts +4 -2
  11. package/src/adapters/cursor/protobuf-events.ts +176 -4
  12. package/src/adapters/cursor/request-builder.ts +15 -4
  13. package/src/adapters/cursor/tool-definitions.ts +118 -2
  14. package/src/adapters/google.ts +15 -5
  15. package/src/adapters/openai-chat.ts +24 -2
  16. package/src/adapters/openai-responses.ts +2 -1
  17. package/src/bridge.ts +9 -5
  18. package/src/chat/outbound.ts +4 -3
  19. package/src/claude/desktop-3p.ts +222 -2
  20. package/src/claude/outbound.ts +15 -6
  21. package/src/cli/account-api.ts +4 -0
  22. package/src/cli/account-extended.ts +112 -0
  23. package/src/cli/account.ts +23 -6
  24. package/src/cli/claude-desktop.ts +26 -3
  25. package/src/cli/config-command.ts +9 -0
  26. package/src/cli/help.ts +18 -2
  27. package/src/cli/index.ts +277 -55
  28. package/src/cli/models.ts +5 -1
  29. package/src/cli/provider.ts +8 -2
  30. package/src/cli/ready.ts +301 -0
  31. package/src/cli/system-restart-client.ts +146 -0
  32. package/src/cli/tray-proxy.ts +153 -6
  33. package/src/clients/config-export.ts +12 -19
  34. package/src/codex/account-lifecycle.ts +3 -0
  35. package/src/codex/account-namespaces.ts +49 -3
  36. package/src/codex/account-priority.ts +83 -0
  37. package/src/codex/auth-api.ts +83 -0
  38. package/src/codex/auth-context.ts +5 -2
  39. package/src/codex/catalog/provider-fetch.ts +11 -0
  40. package/src/codex/catalog/sync.ts +23 -1
  41. package/src/codex/codex-write-lock.ts +16 -4
  42. package/src/codex/desired-state.ts +37 -4
  43. package/src/codex/history-job.ts +15 -5
  44. package/src/codex/history-provider.ts +31 -14
  45. package/src/codex/history-worker.ts +28 -4
  46. package/src/codex/inject-coordination.ts +13 -1
  47. package/src/codex/inject.ts +360 -66
  48. package/src/codex/internal/history-writer.ts +1 -1
  49. package/src/codex/native-main-lock-file.ts +5 -1
  50. package/src/codex/native-main-owner.ts +17 -3
  51. package/src/codex/native-profile-manager.ts +19 -0
  52. package/src/codex/native-profile-startup.ts +8 -0
  53. package/src/codex/native-residue.ts +140 -27
  54. package/src/codex/pool-rotation.ts +74 -4
  55. package/src/codex/refresh.ts +7 -0
  56. package/src/codex/routing.ts +177 -36
  57. package/src/codex/subagent-model-fallback.ts +34 -4
  58. package/src/codex/sync.ts +61 -0
  59. package/src/codex/upstream-host-health.ts +329 -31
  60. package/src/combos/request.ts +2 -0
  61. package/src/config.ts +221 -2
  62. package/src/images/loop.ts +1 -1
  63. package/src/integrations/native/ownership-preflight.ts +39 -2
  64. package/src/lib/bun-stream-caps.ts +3 -3
  65. package/src/lib/sse-decoder.ts +41 -0
  66. package/src/lib/system-restart-contract.ts +73 -0
  67. package/src/lib/windows-secret-acl.ts +141 -39
  68. package/src/lib/windows-user-principal.ts +283 -0
  69. package/src/lib/winsw.ts +18 -2
  70. package/src/oauth/key-providers.ts +12 -0
  71. package/src/providers/derive.ts +54 -2
  72. package/src/providers/free-directory.ts +6 -5
  73. package/src/providers/model-discovery.ts +9 -3
  74. package/src/providers/quota.ts +592 -0
  75. package/src/providers/registry.ts +316 -13
  76. package/src/responses/parser.ts +26 -10
  77. package/src/responses/reasoning-replay-cache.ts +1 -0
  78. package/src/routing/profile-namespace.ts +15 -0
  79. package/src/routing/profile.ts +2 -1
  80. package/src/server/auth-cors.ts +44 -13
  81. package/src/server/chat-completions.ts +0 -4
  82. package/src/server/claude-messages.ts +73 -15
  83. package/src/server/github-copilot-responses-repair.ts +338 -0
  84. package/src/server/index.ts +328 -111
  85. package/src/server/lifecycle.ts +36 -0
  86. package/src/server/management/agent-settings-routes.ts +147 -56
  87. package/src/server/management/config-routes.ts +7 -2
  88. package/src/server/management/context.ts +4 -0
  89. package/src/server/management/native-integration-routes.ts +199 -20
  90. package/src/server/management/provider-routes.ts +41 -0
  91. package/src/server/management/routing-profile-routes.ts +234 -5
  92. package/src/server/management/system-restart.ts +12 -10
  93. package/src/server/management/system-routes.ts +20 -0
  94. package/src/server/management-auth.ts +51 -3
  95. package/src/server/ports.ts +41 -1
  96. package/src/server/proxy-liveness.ts +129 -4
  97. package/src/server/readiness.ts +99 -0
  98. package/src/server/relay.ts +113 -97
  99. package/src/server/request-log.ts +10 -4
  100. package/src/server/responses/compact.ts +107 -12
  101. package/src/server/responses/core.ts +220 -39
  102. package/src/server/responses-item-id-repair.ts +22 -3
  103. package/src/server/responses-model-rewrite.ts +29 -0
  104. package/src/server/sse-frame-buffer.ts +292 -0
  105. package/src/server/sse-payload-rewrite.ts +25 -14
  106. package/src/server/ws-bridge.ts +27 -22
  107. package/src/service-manager-probe.ts +520 -10
  108. package/src/service.ts +134 -2
  109. package/src/storage/worker-lifecycle.ts +14 -14
  110. package/src/tray/windows-tray.ps1 +74 -9
  111. package/src/types.ts +68 -2
  112. package/src/update/index.ts +12 -0
  113. package/src/update/job.ts +392 -18
  114. package/src/update/npm-cache-preflight.d.mts +47 -0
  115. package/src/update/npm-cache-preflight.mjs +201 -0
  116. package/src/usage/log.ts +1 -1
  117. package/src/vision/index.ts +77 -2
  118. package/src/web-search/loop.ts +1 -1
  119. package/src/web-search/parse.ts +4 -1
  120. package/gui/dist/assets/index-BKVqyYqT.js +0 -70
  121. package/gui/dist/assets/index-Ca_3269W.css +0 -1
package/src/service.ts CHANGED
@@ -2177,6 +2177,11 @@ type ServiceOps = {
2177
2177
  status: () => string; uninstall: () => void;
2178
2178
  };
2179
2179
 
2180
+ type ServiceInstallCleanupOps = {
2181
+ status: () => string | null;
2182
+ stop: () => void;
2183
+ };
2184
+
2180
2185
  function platformOps(backend: ServiceBackend = "scheduler"): ServiceOps | null {
2181
2186
  if (process.platform === "darwin")
2182
2187
  return { install: installLaunchd, start: startLaunchd, stop: stopLaunchd, status: statusLaunchd, uninstall: uninstallLaunchd };
@@ -2202,6 +2207,67 @@ function platformOps(backend: ServiceBackend = "scheduler"): ServiceOps | null {
2202
2207
  return null;
2203
2208
  }
2204
2209
 
2210
+ /**
2211
+ * Install-only manager operations. Unlike the ordinary status/stop helpers, these
2212
+ * distinguish confirmed absence from a failed manager query and propagate every
2213
+ * non-benign stop failure. Installing new assets is unsafe while either answer is
2214
+ * unknown because an old manager may still respawn a listener on the target port.
2215
+ */
2216
+ function platformServiceInstallCleanupOps(backend: ServiceBackend): ServiceInstallCleanupOps | null {
2217
+ if (process.platform === "darwin") {
2218
+ return {
2219
+ status: () => {
2220
+ const listing = sh("launchctl list");
2221
+ return listing.split("\n").some(line => line.includes(LABEL)) ? listing : null;
2222
+ },
2223
+ stop: () => { sh(`launchctl unload "${plistPath()}"`); },
2224
+ };
2225
+ }
2226
+ if (process.platform === "win32") {
2227
+ if (backend === "native") {
2228
+ return {
2229
+ status: () => {
2230
+ const status = statusWinswRaw();
2231
+ if (status === "unknown") throw new Error("Native service status could not be verified.");
2232
+ return status === "nonexistent" ? null : status;
2233
+ },
2234
+ stop: stopWinswService,
2235
+ };
2236
+ }
2237
+ return {
2238
+ status: () => {
2239
+ const probe = probeWindowsSchedulerTask(TASK);
2240
+ if (probe.status === "unknown") throw new Error(`Task Scheduler status could not be verified: ${probe.detail}`);
2241
+ return probe.status === "present" ? "present" : null;
2242
+ },
2243
+ stop: () => {
2244
+ try {
2245
+ schtasks(["/end", "/tn", TASK]);
2246
+ } catch (error) {
2247
+ if (!isWindowsSchedulerEndBenign(error)) throw error;
2248
+ }
2249
+ },
2250
+ };
2251
+ }
2252
+ if (process.platform === "linux") {
2253
+ return {
2254
+ status: () => {
2255
+ // `list-unit-files <name>` exits non-zero when the unit has never been
2256
+ // installed, which made a clean first install look like an unknown manager
2257
+ // failure. `show LoadState` gives us the tri-state we actually need: a
2258
+ // healthy user manager returns `not-found` for a missing unit, while an
2259
+ // unreachable/permission-denied manager still makes `sh()` throw and the
2260
+ // caller therefore fails closed.
2261
+ const loadState = sh(`systemctl --user show ${TASK} --property=LoadState --value`).trim().toLowerCase();
2262
+ if (!loadState) throw new Error("systemd service status could not be verified.");
2263
+ return loadState === "not-found" ? null : loadState;
2264
+ },
2265
+ stop: () => { sh(`systemctl --user stop ${TASK}`); },
2266
+ };
2267
+ }
2268
+ return null;
2269
+ }
2270
+
2205
2271
  type TrackedProxyCleanupResult = "none" | "stale" | "stopped";
2206
2272
 
2207
2273
  function verifiedKillTarget(pid: number | null | undefined): number | null {
@@ -2297,6 +2363,60 @@ async function stopTrackedProxyForServiceCommand(): Promise<TrackedProxyCleanupR
2297
2363
  }
2298
2364
  }
2299
2365
 
2366
+ export interface ServiceInstallPreparationDeps {
2367
+ diagnose?: () => ServiceDiagnostic;
2368
+ managerOps?: (backend: ServiceBackend) => ServiceInstallCleanupOps | null;
2369
+ stopTrackedProxy?: () => Promise<unknown>;
2370
+ platform?: NodeJS.Platform;
2371
+ }
2372
+
2373
+ /**
2374
+ * Stop every manager that could own the install port, then stop the tracked
2375
+ * standalone listener. Any unknown status or cleanup failure rejects, so callers
2376
+ * cannot write assets or report success over a surviving old listener.
2377
+ */
2378
+ export async function prepareServiceInstall(
2379
+ requestedBackend: ServiceBackend,
2380
+ deps: ServiceInstallPreparationDeps = {},
2381
+ ): Promise<void> {
2382
+ const diagnostic = (deps.diagnose ?? diagnoseService)();
2383
+ const platform = deps.platform ?? process.platform;
2384
+ const resolveOps = deps.managerOps ?? platformServiceInstallCleanupOps;
2385
+ const backends: ServiceBackend[] = [];
2386
+ const addBackend = (backend: ServiceBackend) => {
2387
+ if (!backends.includes(backend)) backends.push(backend);
2388
+ };
2389
+
2390
+ if (platform === "win32") {
2391
+ // The recorded backend owns the old installation and must be stopped first.
2392
+ // A conflicting diagnostic means both managers exist, so stop both even when
2393
+ // the requested backend happens to match the recorded one.
2394
+ if (diagnostic.backend === "scheduler" || diagnostic.backend === "native") {
2395
+ addBackend(diagnostic.backend);
2396
+ if (diagnostic.conflict) addBackend(diagnostic.backend === "scheduler" ? "native" : "scheduler");
2397
+ }
2398
+ addBackend(requestedBackend);
2399
+ } else {
2400
+ addBackend(requestedBackend);
2401
+ }
2402
+
2403
+ for (const backend of backends) {
2404
+ const manager = resolveOps(backend);
2405
+ if (!manager) throw new Error(`Background service manager is unavailable for ${backend}.`);
2406
+ if (manager.status() !== null) manager.stop();
2407
+ }
2408
+ await (deps.stopTrackedProxy ?? stopTrackedProxyIfRunning)();
2409
+ }
2410
+
2411
+ export async function installServiceSafely(
2412
+ requestedBackend: ServiceBackend,
2413
+ install: () => void | Promise<void>,
2414
+ deps: ServiceInstallPreparationDeps = {},
2415
+ ): Promise<void> {
2416
+ await prepareServiceInstall(requestedBackend, deps);
2417
+ await install();
2418
+ }
2419
+
2300
2420
  /**
2301
2421
  * If a service is installed, stop it so the process manager doesn't respawn after `ocx stop`.
2302
2422
  * Returns true if a service was found and stopped.
@@ -2645,7 +2765,19 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
2645
2765
  case "install":
2646
2766
  assertServiceEnvironmentMatchesInstall();
2647
2767
  assertServiceAuthEnvironment();
2648
- await ops.install();
2768
+ // A manually started proxy can still own the configured port while the service
2769
+ // registration is absent or unloaded. Stop both the registered manager and any
2770
+ // tracked standalone listener before loading the freshly written service assets.
2771
+ // Otherwise launchd/Task Scheduler can register successfully while its child
2772
+ // restart-loops on EADDRINUSE, and the old standalone process makes the install
2773
+ // verification report a false success.
2774
+ try {
2775
+ await installServiceSafely(backend, ops.install);
2776
+ } catch (error) {
2777
+ console.error(`❌ Service install cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
2778
+ process.exitCode = 1;
2779
+ break;
2780
+ }
2649
2781
  // The wrapper was written moments ago in this process, so the configured port
2650
2782
  // and the baked one cannot have diverged yet — unlike `start`, which reads the
2651
2783
  // installed artifact instead.
@@ -2741,4 +2873,4 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
2741
2873
  console.error(" --native (Windows only): register a real SCM service via WinSW instead of Task Scheduler.");
2742
2874
  process.exit(1);
2743
2875
  }
2744
- }
2876
+ }
@@ -11,10 +11,9 @@
11
11
  * time (so we cannot miss `self.close()` / early exit), stays in `liveWorkers`
12
12
  * until that close settles, and `drainStorageWorkers()` joins every in-flight
13
13
  * terminate. Spawns are serialized through `withStorageWorkerSpawnGate` so the
14
- * next Worker cannot be created until prior threads have exited. On Windows and
15
- * macOS, a post-close settle covers the OS join gap Bun does not expose
16
- * (Windows unbalanced join panic; macOS Silicon balanced-count segfault under
17
- * `bun test --isolate`).
14
+ * next Worker cannot be created until prior threads have exited. A post-close
15
+ * settle covers the OS/runtime join gap Bun does not expose on Windows, macOS,
16
+ * and Linux (including Bun 1.3.14 isolate crashes and Linux `epoll_ctl` reuse).
18
17
  */
19
18
 
20
19
  import { createAdmissionGate, type AdmissionMetrics, type AdmissionReservation } from "../lib/admission";
@@ -52,15 +51,15 @@ let spawnCancelEpoch = 0;
52
51
  /**
53
52
  * OS-join gap after the `close` event on platforms where Bun's Worker reclaim
54
53
  * races the isolate/file boundary (not a CI job-timeout bump).
55
- * Windows GHA at 250ms and 750ms still left `workers_spawned(N)
56
- * workers_terminated(N-1)` panics under isolate (seen mid
57
- * `storage-mutation-race` with 11/10). 1500ms covers deferred reclaim under
58
- * stacked policy/restore workers. Darwin uses 250ms.
54
+ * Windows GHA at 250ms and 750ms still left `workers_spawned(N)`
55
+ * `workers_terminated(N-1)` panics under isolate, so Windows keeps 1500ms.
56
+ * Darwin and Linux use a shorter settle for the balanced-count/epoll reclaim
57
+ * window seen on Bun 1.3.14.
59
58
  */
60
- const WORKER_OS_JOIN_MS = process.platform === "win32" ? 1_500 : 250;
61
-
62
- function needsWorkerOsJoinSettle(): boolean {
63
- return process.platform === "win32" || process.platform === "darwin";
59
+ export function storageWorkerOsJoinSettleMs(platform = process.platform): number {
60
+ if (platform === "win32") return 1_500;
61
+ if (platform === "darwin" || platform === "linux") return 250;
62
+ return 0;
64
63
  }
65
64
 
66
65
  /** Invalidate spawn callbacks still waiting on the gate (reset / server drain). */
@@ -181,9 +180,10 @@ export function terminateStorageWorker(worker: Worker, timeoutMs = 5_000): Promi
181
180
  // only forces `closed`, it does not prove the OS thread has exited.
182
181
  // Callers that catch and continue (e.g. drainAndShutdown) still need
183
182
  // that gap before the next isolate reclaim or server.stop.
184
- if (needsWorkerOsJoinSettle()) {
183
+ const settleMs = storageWorkerOsJoinSettleMs();
184
+ if (settleMs > 0) {
185
185
  await Bun.sleep(0);
186
- await Bun.sleep(WORKER_OS_JOIN_MS);
186
+ await Bun.sleep(settleMs);
187
187
  }
188
188
  if (timedOut) {
189
189
  throw new Error(`storage worker did not exit within ${timeoutMs}ms`);
@@ -84,7 +84,7 @@ function ConvertTo-NativeArgument([string]$Value) {
84
84
  return '"' + $Value + '"'
85
85
  }
86
86
 
87
- function Start-OcxCommand([string[]]$CommandArgs) {
87
+ function Start-OcxCommand([string[]]$CommandArgs, [switch]$TrackExit) {
88
88
  try {
89
89
  $allArgs = @($CliPath) + $CommandArgs
90
90
  $psi = New-Object System.Diagnostics.ProcessStartInfo
@@ -102,8 +102,10 @@ function Start-OcxCommand([string[]]$CommandArgs) {
102
102
  $psi.EnvironmentVariables["OCX_BUN_RUNTIME_PATH"] = $BunPath
103
103
  }
104
104
  $process = [System.Diagnostics.Process]::Start($psi)
105
- if ($null -ne $process) { $process.Dispose() }
105
+ if ($null -eq $process) { throw "Process did not start" }
106
106
  Write-ActionLog "dispatched $($CommandArgs -join ' ')"
107
+ if ($TrackExit) { return $process }
108
+ $process.Dispose()
107
109
  return $true
108
110
  } catch {
109
111
  Write-ActionLog "launch failed: $($_.Exception.GetType().Name)"
@@ -172,18 +174,40 @@ $script:pendingAction = $null
172
174
  $script:pendingStarted = 0L
173
175
  $script:pendingDeadline = 0L
174
176
  $script:pendingOldProxyPid = $null
177
+ $script:pendingProcess = $null
175
178
 
176
179
  function Set-PendingAction([string]$Action, [int]$TimeoutSeconds) {
180
+ if ($null -ne $script:pendingAction) {
181
+ Write-ActionLog "$Action ignored because $($script:pendingAction) is still pending"
182
+ return $false
183
+ }
184
+ if ($null -ne $script:pendingProcess) {
185
+ try {
186
+ $script:pendingProcess.Dispose()
187
+ } catch {
188
+ Write-ActionLog "pending process dispose failed: $($_.Exception.GetType().Name)"
189
+ }
190
+ $script:pendingProcess = $null
191
+ }
177
192
  $script:pendingAction = $Action
178
193
  $script:pendingStarted = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
179
194
  $script:pendingDeadline = $script:pendingStarted + ($TimeoutSeconds * 1000)
180
195
  $script:pendingOldProxyPid = $script:proxyPid
196
+ return $true
181
197
  }
182
198
 
183
199
  function Complete-PendingAction([bool]$Success) {
184
200
  if ($null -eq $script:pendingAction) { return }
185
201
  $action = $script:pendingAction
186
202
  $script:pendingAction = $null
203
+ if ($null -ne $script:pendingProcess) {
204
+ try {
205
+ $script:pendingProcess.Dispose()
206
+ } catch {
207
+ Write-ActionLog "pending process dispose failed: $($_.Exception.GetType().Name)"
208
+ }
209
+ $script:pendingProcess = $null
210
+ }
187
211
  if ($Success) {
188
212
  Write-ActionLog "$action completed (port=$($script:port), pid=$($script:proxyPid))"
189
213
  $notify.ShowBalloonTip(2500, "opencodex", "$action completed.", [System.Windows.Forms.ToolTipIcon]::Info)
@@ -226,6 +250,11 @@ function Update-TrayState {
226
250
  $stopItem.Enabled = $false
227
251
  $restartItem.Enabled = $false
228
252
  }
253
+ if ($null -ne $script:pendingAction) {
254
+ $startItem.Enabled = $false
255
+ $stopItem.Enabled = $false
256
+ $restartItem.Enabled = $false
257
+ }
229
258
  $heartbeat = @{ pid = $PID; timestamp = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() }
230
259
  if ($HostPid -gt 0) { $heartbeat.hostPid = $HostPid }
231
260
  $heartbeatJson = $heartbeat | ConvertTo-Json -Compress
@@ -237,26 +266,59 @@ function Update-TrayState {
237
266
  $reached = ($script:pendingAction -eq "Start Proxy" -and $script:online) -or
238
267
  ($script:pendingAction -eq "Stop Proxy" -and -not $script:online) -or
239
268
  ($script:pendingAction -eq "Restart Proxy" -and $elapsed -gt 3000 -and $script:online -and $script:proxyPid -ne $script:pendingOldProxyPid)
240
- if ($reached) { Complete-PendingAction $true }
269
+ $commandFailed = $false
270
+ if ($null -ne $script:pendingProcess) {
271
+ try {
272
+ $commandFailed = $script:pendingProcess.HasExited -and $script:pendingProcess.ExitCode -ne 0
273
+ } catch {
274
+ Write-ActionLog "pending process result inspection failed: $($_.Exception.GetType().Name)"
275
+ # If we cannot inspect the tracked command, we cannot prove it is still
276
+ # healthy. Fail the pending action instead of silently waiting for a later
277
+ # timeout and presenting an indeterminate process as success-capable.
278
+ $commandFailed = $true
279
+ }
280
+ }
281
+ if ($commandFailed) { Complete-PendingAction $false }
282
+ elseif ($reached) { Complete-PendingAction $true }
241
283
  elseif ($now -gt $script:pendingDeadline) { Complete-PendingAction $false }
242
284
  }
243
285
  }
244
286
 
245
287
  $openItem.add_Click({ Start-OcxCommand @("gui") })
246
288
  $startItem.add_Click({
289
+ if (-not (Set-PendingAction "Start Proxy" 75)) { return }
247
290
  $statusItem.Text = "Proxy: Starting..."
248
- Set-PendingAction "Start Proxy" 15
249
- if (-not (Start-OcxCommand @("__tray-start"))) { $script:pendingAction = $null }
291
+ # service start can spend 20s and the CLI then observes health for another 40s.
292
+ $startProcess = Start-OcxCommand @("__tray-start") -TrackExit
293
+ if ($startProcess -is [System.Diagnostics.Process]) {
294
+ $script:pendingProcess = $startProcess
295
+ } else {
296
+ Complete-PendingAction $false
297
+ }
250
298
  })
251
299
  $stopItem.add_Click({
300
+ if (-not (Set-PendingAction "Stop Proxy" 15)) { return }
252
301
  $statusItem.Text = "Proxy: Stopping..."
253
- Set-PendingAction "Stop Proxy" 15
254
- if (-not (Start-OcxCommand @("stop"))) { $script:pendingAction = $null }
302
+ $stopProcess = Start-OcxCommand @("stop") -TrackExit
303
+ if ($stopProcess -is [System.Diagnostics.Process]) {
304
+ $script:pendingProcess = $stopProcess
305
+ } else {
306
+ Complete-PendingAction $false
307
+ }
255
308
  })
256
309
  $restartItem.add_Click({
310
+ if (-not (Set-PendingAction "Restart Proxy" 160)) { return }
257
311
  $statusItem.Text = "Proxy: Restarting..."
258
- Set-PendingAction "Restart Proxy" 20
259
- if (-not (Start-OcxCommand @("__tray-restart"))) { $script:pendingAction = $null }
312
+ # /api/system/restart may drain active work for 60s and then spend up to 70s
313
+ # handing off to an identity-verified replacement. The tray observes health/PID
314
+ # rather than the detached CLI exit, so keep a watchdog margin around that shared
315
+ # lifecycle budget. The CLI remains the lifecycle owner; the tray never kills.
316
+ $restartProcess = Start-OcxCommand @("__tray-restart") -TrackExit
317
+ if ($restartProcess -is [System.Diagnostics.Process]) {
318
+ $script:pendingProcess = $restartProcess
319
+ } else {
320
+ Complete-PendingAction $false
321
+ }
260
322
  })
261
323
  $logsItem.add_Click({
262
324
  $psi = New-Object System.Diagnostics.ProcessStartInfo
@@ -289,6 +351,9 @@ try {
289
351
  $timer.Stop()
290
352
  $timer.Dispose()
291
353
  $notify.Visible = $false
354
+ if ($null -ne $script:pendingProcess) {
355
+ try { $script:pendingProcess.Dispose() } catch { $null = $_ }
356
+ }
292
357
  $notify.Dispose()
293
358
  foreach ($icon in $script:ownedIcons) { $icon.Dispose() }
294
359
  $menu.Dispose()
package/src/types.ts CHANGED
@@ -2,6 +2,8 @@ import type { KiroOAuthMetadata } from "./oauth/types";
2
2
 
3
3
  export interface OcxParsedRequest {
4
4
  modelId: string;
5
+ /** Client-facing model selector retained for Anthropic routes after wire-model normalization. */
6
+ _responseModelId?: string;
5
7
  /** Selected OpenAI API virtual-model id retained after it rewrites the upstream wire model. */
6
8
  _openAiVirtualSelectedModelId?: string;
7
9
  previousResponseId?: string;
@@ -164,6 +166,8 @@ export interface OcxTool {
164
166
  toolSearch?: boolean;
165
167
  /** Tool definition restored from a prior tool_search output; transports may prioritize it when catalogs are bounded. */
166
168
  loadedFromToolSearch?: boolean;
169
+ /** Cursor-only synthetic exact-match edit tool; never inferred from the wire name. */
170
+ cursorStructuredEdit?: true;
167
171
  /** Synthetic web_search tool: the model's call is executed by the gpt-5.4-mini sidecar, not relayed to Codex. */
168
172
  webSearch?: boolean;
169
173
  /** Synthetic image_gen tool: the model's call is executed by the xAI image bridge sidecar, not relayed to Codex. */
@@ -233,6 +237,20 @@ export interface OcxRequestOptions {
233
237
  frequencyPenalty?: number;
234
238
  /** Responses prompt-cache affinity key. Passthrough preserves it via _rawBody; routed adapters do not consume it unless their upstream wire supports it. */
235
239
  promptCacheKey?: string;
240
+ /**
241
+ * Responses `text.format` (json_schema / json_object), preserved for adapters whose
242
+ * upstream wire has an equivalent. The openai-chat adapter re-nests it as chat
243
+ * `response_format`, the exact inverse of responseFormatToText in src/chat/inbound.ts.
244
+ * The native passthrough ignores it (it forwards `_rawBody.text` verbatim) and Kiro
245
+ * keeps rejecting structured output via `_structuredOutput`.
246
+ */
247
+ textFormat?: {
248
+ type: "json_schema" | "json_object";
249
+ name?: string;
250
+ description?: string;
251
+ schema?: Record<string, unknown>;
252
+ strict?: boolean;
253
+ };
236
254
  }
237
255
 
238
256
  export type OcxMessagePhase = "commentary" | "final_answer";
@@ -553,6 +571,8 @@ export interface OcxClientIntegrationsConfig {
553
571
  codex?: boolean;
554
572
  /** Durable desired state for Grok Build. MISSING MEANS ON. */
555
573
  grok?: boolean;
574
+ /** Durable desired state for Claude Desktop. MISSING MEANS ON. */
575
+ "claude-desktop"?: boolean;
556
576
  }
557
577
 
558
578
  export interface OcxConfig {
@@ -708,6 +728,30 @@ export interface OcxConfig {
708
728
  contextCapValue?: number;
709
729
  /** Bind hostname. Default "127.0.0.1" (loopback only). Set "0.0.0.0" to expose on all interfaces. */
710
730
  hostname?: string;
731
+ /**
732
+ * Optional second listener bound to 127.0.0.1 that admits data-plane requests without a
733
+ * credential (issue #1102).
734
+ *
735
+ * Why a separate listener rather than an exemption on the main one: when `hostname` is a
736
+ * wildcard, every caller needs `x-opencodex-api-key`, but a `codex app-server` spawned
737
+ * directly from the resolved entrypoint never goes through the generated shim and so never
738
+ * inherits the token. Exempting "loopback-looking peers" on the public listener would be
739
+ * unsound — `requestIP()` only proves the last transport hop, and Docker Desktop port
740
+ * forwarding, host-network containers, WSL mirrored networking and tunnels all terminate
741
+ * remote connections locally. Binding a second socket to 127.0.0.1 makes the kernel refuse
742
+ * remote connections outright, so there is no address to judge.
743
+ *
744
+ * The public listener's admission policy is unchanged. This adds an explicit local trust
745
+ * surface: every process on the machine can reach it, spend account quota, and consume paid
746
+ * provider credentials. Off by default; not for multi-tenant hosts.
747
+ *
748
+ * The port is required when enabled and must differ from the proxy port. An OS-assigned port
749
+ * would change across restarts, which would break already-running app-servers holding the
750
+ * previous `base_url` — the exact symptom #1102 reported and we disproved for token rotation.
751
+ */
752
+ unauthenticatedLoopbackListener?:
753
+ | { enabled: false }
754
+ | { enabled: true; port: number };
711
755
  /**
712
756
  * Outbound HTTP(S) proxy URL for provider requests (e.g. "http://user:pass@proxy:8080", or
713
757
  * "${HTTPS_PROXY}"-style env reference). Mirrored into HTTP_PROXY/HTTPS_PROXY at startup when
@@ -762,12 +806,29 @@ export interface OcxConfig {
762
806
  codexAccounts?: CodexAccount[];
763
807
  /** Account ids administratively excluded from future pool selection until resumed. */
764
808
  pausedCodexAccountIds?: string[];
809
+ /**
810
+ * Selection order per account id, higher used earlier; absent = 0. Keyed by id
811
+ * rather than stored on `codexAccounts` rows so the Desktop login (`__main__`),
812
+ * which has no row, can be ordered too. Range -100..100.
813
+ */
814
+ codexAccountPriorities?: Record<string, number>;
815
+ /**
816
+ * Account id the operator last selected by hand. Suppresses upward priority
817
+ * preemption until that account crosses the auto-switch threshold. Stores the
818
+ * id (not a flag) so a stale pin cannot outlive the selection it described.
819
+ */
820
+ activeCodexAccountPinned?: string;
765
821
  /**
766
822
  * Public model-selector namespaces bound to one Codex account. Values are stored account ids;
767
823
  * `"@main"` selects the Codex Desktop/main auth.json account. Account display aliases
768
824
  * are intentionally separate from these selectors.
769
825
  */
770
826
  codexAccountNamespaces?: Record<string, string>;
827
+ /**
828
+ * Picker visibility override for account-qualified native models. When omitted, a non-empty
829
+ * selector map remains visible for compatibility with hand-written configurations.
830
+ */
831
+ codexAccountPickerEnabled?: boolean;
771
832
  /** Active pool account id for next session. undefined = main (passthrough as-is). */
772
833
  activeCodexAccountId?: string;
773
834
  /** Auto-switch threshold (0-100). Default 80. 0 = disabled. */
@@ -778,6 +839,11 @@ export interface OcxConfig {
778
839
  accountPoolStickyLimit?: number;
779
840
  /** Consecutive non-2xx upstream responses before switching future new threads. Default 3. 0 = disabled. */
780
841
  upstreamFailoverThreshold?: number;
842
+ /**
843
+ * Opt-in provider-origin circuit threshold for proven pre-connection reachability failures.
844
+ * Default 0 (disabled); range 0..20. The circuit never counts timeouts or HTTP responses.
845
+ */
846
+ upstreamHostCircuitThreshold?: number;
781
847
  /**
782
848
  * Opt-in Anthropic OAuth account pool (#294). Default OFF.
783
849
  * Failover on 429 + sticky affinity; new sessions may pick lowest known 5h usage.
@@ -1139,9 +1205,9 @@ export interface OcxProviderConfig {
1139
1205
  * full set so the user can pick). See devlog issue_052_provider-model-allowlist.
1140
1206
  */
1141
1207
  selectedModels?: string[];
1142
- /** Provider-wide Codex-visible context-window cap for routed catalog entries. */
1208
+ /** Provider-wide fallback when context metadata is absent; otherwise caps the reported window. */
1143
1209
  contextWindow?: number;
1144
- /** Model-specific Codex-visible context-window caps. Values cap live metadata, never raise it. */
1210
+ /** Per-model fallback when context metadata is absent; otherwise caps the reported window. */
1145
1211
  modelContextWindows?: Record<string, number>;
1146
1212
  /** Model-specific Codex catalog input modalities, e.g. ["text"] or ["text", "image"]. */
1147
1213
  modelInputModalities?: Record<string, string[]>;
@@ -4,6 +4,10 @@ import { fileURLToPath } from "node:url";
4
4
  import { dirname, join } from "node:path";
5
5
  import { getConfigDir, loadConfig, readPid, readRuntimePort } from "../config";
6
6
  import { npmInvocation } from "./npm-invocation.mjs";
7
+ import {
8
+ npmCachePreflightFailureMessage,
9
+ runNpmCachePreflight,
10
+ } from "./npm-cache-preflight.mjs";
7
11
  import { handoffWindowsTrayForUpdate, planWindowsTrayUpdate } from "./tray-update-plan.mjs";
8
12
  import { withProcessRuntimeProvenance } from "../lib/bun-runtime";
9
13
 
@@ -178,6 +182,14 @@ export async function runUpdate(): Promise<void> {
178
182
  console.log(`Verified ${PKG}@${latest} integrity metadata ${integrity.integrity.slice(0, 24)}…`);
179
183
  }
180
184
 
185
+ if (installer === "npm") {
186
+ const cachePreflight = runNpmCachePreflight();
187
+ if (!cachePreflight.ok) {
188
+ console.error(`⚠️ ${npmCachePreflightFailureMessage(cachePreflight.reason)}. Aborting before stopping the proxy.`);
189
+ process.exit(1);
190
+ }
191
+ }
192
+
181
193
  const { bin, args: cmdArgs } = updateCommand(installer, tag, latest);
182
194
  const target = updateSpawnTarget(bin, cmdArgs);
183
195
  if (!target) {