@bitkyc08/opencodex 2.67.0 → 2.68.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 (257) hide show
  1. package/bin/ocx.mjs +3 -0
  2. package/gui/dist/assets/App-DCBismRi.css +1 -0
  3. package/gui/dist/assets/App-mpsibsFj.js +51 -0
  4. package/gui/dist/assets/Tray-Bn8WhKBH.js +1 -0
  5. package/gui/dist/assets/{index-DtNmX7hW.css → index-BDUBS8PW.css} +1 -1
  6. package/gui/dist/assets/index-kUE8tuAK.js +86 -0
  7. package/gui/dist/assets/quota-summary-DL-bNuNL.js +1 -0
  8. package/gui/dist/index.html +21 -2
  9. package/package.json +1 -1
  10. package/src/adapters/anthropic-image-guard.ts +13 -1
  11. package/src/adapters/anthropic-image-normalize.ts +3 -2
  12. package/src/adapters/anthropic-output-schema.ts +36 -0
  13. package/src/adapters/anthropic.ts +10 -2
  14. package/src/adapters/base.ts +7 -0
  15. package/src/adapters/codebuddy/live-models.ts +180 -0
  16. package/src/adapters/coding-agent/protocol.ts +91 -12
  17. package/src/adapters/coding-agent/turn.ts +32 -27
  18. package/src/adapters/devin/cloud-direct/index.ts +1 -0
  19. package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +18 -4
  20. package/src/adapters/devin.ts +9 -6
  21. package/src/adapters/google-http.ts +4 -4
  22. package/src/adapters/google.ts +93 -3
  23. package/src/adapters/kiro/adapter.ts +6 -2
  24. package/src/adapters/kiro/stream.ts +12 -2
  25. package/src/adapters/kiro/usage.ts +3 -1
  26. package/src/adapters/kiro-errors.ts +11 -1
  27. package/src/adapters/kiro-events.ts +27 -1
  28. package/src/adapters/kiro-refusal.ts +30 -0
  29. package/src/adapters/kiro-retry.ts +71 -31
  30. package/src/adapters/openai-chat/deepseek-artifact-schema.ts +45 -0
  31. package/src/adapters/openai-chat/messages.ts +31 -4
  32. package/src/adapters/openai-chat/serialized-tool-call-content.ts +52 -7
  33. package/src/adapters/openai-chat/tool-call-id-remint.ts +65 -0
  34. package/src/adapters/openai-chat/tool-schema.ts +6 -2
  35. package/src/adapters/openai-chat.ts +2 -2
  36. package/src/adapters/openai-responses/muse-tool-choice.ts +31 -0
  37. package/src/adapters/openai-responses/passthrough.ts +9 -3
  38. package/src/adapters/opencode-go-additional-tools.ts +19 -10
  39. package/src/adapters/physical-send.ts +10 -3
  40. package/src/adapters/registry.ts +2 -1
  41. package/src/adapters/responses-tool-schema.ts +31 -3
  42. package/src/adapters/run-turn-queue.ts +63 -23
  43. package/src/adapters/unique-tool-call-ids.ts +63 -0
  44. package/src/adapters/xai-web-search.ts +32 -2
  45. package/src/bridge/sse.ts +14 -2
  46. package/src/chat/inbound.ts +20 -6
  47. package/src/claude/agents-inject.ts +4 -0
  48. package/src/claude/desktop-3p.ts +53 -13
  49. package/src/claude/desktop-profile.ts +41 -4
  50. package/src/claude/inbound-model-options.ts +14 -2
  51. package/src/claude/inbound.ts +1 -1
  52. package/src/claude/intercept/connect-proxy.ts +17 -1
  53. package/src/claude/intercept/local-ca.ts +7 -1
  54. package/src/claude/message-threads.ts +28 -0
  55. package/src/cli/account-api.ts +13 -0
  56. package/src/cli/account-auth.ts +49 -2
  57. package/src/cli/account-extended.ts +4 -2
  58. package/src/cli/account.ts +4 -2
  59. package/src/cli/capabilities.ts +17 -0
  60. package/src/cli/claude.ts +3 -1
  61. package/src/cli/dispatch.ts +62 -5
  62. package/src/cli/index.ts +84 -79
  63. package/src/cli/minimax.ts +4 -2
  64. package/src/cli/opencode.ts +6 -1
  65. package/src/cli/restart-handoff.ts +110 -0
  66. package/src/cli/status.ts +51 -0
  67. package/src/client/connect.ts +34 -15
  68. package/src/client/link-ingress.ts +102 -0
  69. package/src/client/link-join.ts +37 -16
  70. package/src/client/link-relay.ts +228 -45
  71. package/src/client/link-state.ts +54 -0
  72. package/src/client/link-status.ts +39 -0
  73. package/src/client/link-teardown.ts +2 -2
  74. package/src/client/link-tunnel.ts +615 -86
  75. package/src/client/machine-api.ts +10 -4
  76. package/src/client/machine-listener.ts +57 -10
  77. package/src/client/runtime.ts +210 -35
  78. package/src/clients/config-export/constants.ts +1 -12
  79. package/src/clients/config-export/contracts.ts +2 -0
  80. package/src/clients/config-export/model-metadata.ts +9 -3
  81. package/src/clients/config-export/omp.ts +1 -1
  82. package/src/clients/config-export/zcode-store.ts +2 -2
  83. package/src/clients/config-export.ts +36 -3
  84. package/src/codex/app-server-processes.ts +32 -0
  85. package/src/codex/app-server-restart-service.ts +20 -3
  86. package/src/codex/catalog/access-programs.ts +50 -0
  87. package/src/codex/catalog/build-entries.ts +13 -0
  88. package/src/codex/catalog/derive-entry.ts +1 -0
  89. package/src/codex/catalog/model-hints.ts +11 -0
  90. package/src/codex/catalog/parsing.ts +6 -2
  91. package/src/codex/catalog/provider-models.ts +81 -0
  92. package/src/codex/catalog/retained-sync.ts +3 -0
  93. package/src/codex/codex-write-lock.ts +2 -2
  94. package/src/codex/convergence.ts +2 -0
  95. package/src/codex/desired-state.ts +14 -4
  96. package/src/codex/home.ts +21 -3
  97. package/src/codex/inject/restore.ts +17 -0
  98. package/src/codex/inject/routing-classify.ts +3 -2
  99. package/src/codex/inject-coordination.ts +4 -1
  100. package/src/codex/inject.ts +8 -1
  101. package/src/codex/journal.ts +6 -2
  102. package/src/codex/management-convergence.ts +9 -0
  103. package/src/codex/model-entitlements.ts +28 -5
  104. package/src/codex/routing/idle-window.ts +58 -0
  105. package/src/codex/routing-drift.ts +134 -0
  106. package/src/codex/routing-healer.ts +419 -0
  107. package/src/codex/routing.ts +10 -0
  108. package/src/codex/runtime.ts +188 -57
  109. package/src/codex/sibling-handoff.ts +71 -0
  110. package/src/codex/sibling-start.ts +139 -0
  111. package/src/codex/sync.ts +5 -3
  112. package/src/combos/failover.ts +32 -5
  113. package/src/combos/request.ts +11 -3
  114. package/src/combos/reset-window.ts +10 -5
  115. package/src/combos/resolve.ts +36 -6
  116. package/src/config/diagnostics.ts +2 -1
  117. package/src/config/load-degrade.ts +2 -0
  118. package/src/config/paths.ts +7 -1
  119. package/src/config/process-state.ts +10 -1
  120. package/src/config/schema/compaction-recovery.ts +15 -0
  121. package/src/config/schema/config-schema.ts +2 -0
  122. package/src/config/schema/leaf-validators.ts +9 -3
  123. package/src/generated/compatibility-version.json +360 -204
  124. package/src/images/loop.ts +47 -32
  125. package/src/integrations/catalog-refresh.ts +4 -0
  126. package/src/integrations/omp-yaml-source.ts +1 -0
  127. package/src/lib/claude-request-projection.ts +101 -0
  128. package/src/lib/local-account-switch-capability.ts +48 -0
  129. package/src/lib/local-upstream.ts +146 -0
  130. package/src/lib/package-tree-integrity.ts +25 -3
  131. package/src/lib/package-tree-retarget.ts +130 -0
  132. package/src/lib/process-control.ts +4 -1
  133. package/src/lib/system-restart-contract.ts +106 -0
  134. package/src/lib/upstream-retry.ts +57 -1
  135. package/src/link/ports.ts +9 -0
  136. package/src/link/ssh-argv.ts +16 -0
  137. package/src/link/ssh-runner.ts +70 -2
  138. package/src/link/tunnel-state.ts +58 -9
  139. package/src/oauth/account-quota-rank.ts +29 -18
  140. package/src/oauth/generic-account-failover.ts +179 -22
  141. package/src/oauth/index.ts +17 -4
  142. package/src/oauth/kiro-account-load.ts +107 -0
  143. package/src/oauth/kiro-device-login.ts +312 -0
  144. package/src/oauth/kiro-terminal-failover.ts +28 -0
  145. package/src/oauth/login-flow-state.ts +7 -1
  146. package/src/oauth/pool-settings-capability.ts +23 -14
  147. package/src/oauth/store.ts +56 -3
  148. package/src/oauth/types.ts +4 -0
  149. package/src/plugins/loader.ts +350 -0
  150. package/src/plugins/upstream-hooks.ts +119 -0
  151. package/src/protocols/encoders/adapter-events.ts +9 -2
  152. package/src/providers/account-quota-disk.ts +42 -4
  153. package/src/providers/codebuddy-models.ts +6 -2
  154. package/src/providers/command-code-efforts.ts +33 -10
  155. package/src/providers/kiro-account-state-disk.ts +44 -0
  156. package/src/providers/kiro-model-catalog.ts +162 -0
  157. package/src/providers/kiro-models.ts +5 -4
  158. package/src/providers/kiro-quota-metrics.ts +35 -0
  159. package/src/providers/kiro-usage.ts +118 -14
  160. package/src/providers/quota/account-cache.ts +43 -7
  161. package/src/providers/quota/antigravity.ts +8 -3
  162. package/src/providers/quota/kiro-account-probe.ts +12 -0
  163. package/src/providers/quota/vendor-probes-key.ts +31 -26
  164. package/src/providers/quota/vendor-probes-oauth.ts +16 -7
  165. package/src/providers/quota-types.ts +14 -0
  166. package/src/providers/quota.ts +21 -21
  167. package/src/providers/registry/entries-extended.ts +5 -3
  168. package/src/providers/request-pacing.ts +410 -12
  169. package/src/remote-control/workspace-codex-runtime.ts +4 -3
  170. package/src/responses/citation-markers.ts +132 -65
  171. package/src/responses/hosted-tool-policy.ts +14 -3
  172. package/src/responses/parser-content.ts +3 -2
  173. package/src/responses/parser.ts +4 -1
  174. package/src/responses/schema.ts +4 -0
  175. package/src/responses/visualization-directives.ts +182 -0
  176. package/src/server/chat-native-sse.ts +24 -12
  177. package/src/server/chat-native.ts +137 -68
  178. package/src/server/claude-messages.ts +147 -12
  179. package/src/server/index/link-listener.ts +7 -0
  180. package/src/server/index/optional-listeners.ts +4 -1
  181. package/src/server/index/package-tree-guard.ts +35 -16
  182. package/src/server/index/serve-options.ts +4 -2
  183. package/src/server/index/startup-warnings.ts +6 -0
  184. package/src/server/index.ts +7 -7
  185. package/src/server/inference/client-encoder-delivery.ts +3 -0
  186. package/src/server/inference/context.ts +30 -2
  187. package/src/server/local-account-switch-auth.ts +79 -0
  188. package/src/server/management/agent-settings-routes.ts +11 -3
  189. package/src/server/management/config-routes.ts +28 -11
  190. package/src/server/management/context.ts +2 -0
  191. package/src/server/management/link-routes.ts +123 -39
  192. package/src/server/management/logs-usage-routes.ts +9 -42
  193. package/src/server/management/model-rows.ts +1 -0
  194. package/src/server/management/oauth-account-routes.ts +67 -12
  195. package/src/server/management/route-registry.ts +5 -5
  196. package/src/server/management/sibling-guard.ts +60 -0
  197. package/src/server/management/storage-log-guard-routes.ts +72 -6
  198. package/src/server/management/system-restart.ts +92 -135
  199. package/src/server/management/system-routes.ts +1 -1
  200. package/src/server/management-api.ts +31 -3
  201. package/src/server/management-auth.ts +3 -0
  202. package/src/server/port-reclaim.ts +111 -1
  203. package/src/server/proxy-liveness.ts +16 -0
  204. package/src/server/relay.ts +16 -15
  205. package/src/server/request-log-filter.ts +28 -27
  206. package/src/server/request-log.ts +7 -3
  207. package/src/server/request-metrics.ts +28 -0
  208. package/src/server/responses/adapter-continuation.ts +151 -21
  209. package/src/server/responses/adapter-delivery.ts +21 -7
  210. package/src/server/responses/adapter-dispatch.ts +239 -51
  211. package/src/server/responses/codex-ws-pool.ts +5 -4
  212. package/src/server/responses/codex-ws-request.ts +4 -1
  213. package/src/server/responses/compact.ts +8 -4
  214. package/src/server/responses/compaction-recovery-policy.ts +116 -0
  215. package/src/server/responses/compaction-recovery.ts +330 -0
  216. package/src/server/responses/core-combo-failure.ts +1 -0
  217. package/src/server/responses/core-combo.ts +5 -1
  218. package/src/server/responses/core-lifetime.ts +22 -0
  219. package/src/server/responses/core-options.ts +16 -1
  220. package/src/server/responses/core.ts +14 -4
  221. package/src/server/responses/empty-completion-guard.ts +2 -0
  222. package/src/server/responses/fetch-helpers.ts +82 -31
  223. package/src/server/responses/passthrough-delivery.ts +3 -2
  224. package/src/server/responses/passthrough-dispatch.ts +12 -2
  225. package/src/server/responses/passthrough-execution.ts +2 -2
  226. package/src/server/responses/request-send-budget.ts +21 -5
  227. package/src/server/responses/request-transport.ts +116 -18
  228. package/src/server/responses/reset-replay.ts +32 -0
  229. package/src/server/responses/run-turn-execution.ts +136 -19
  230. package/src/server/responses/sidecar-execution.ts +41 -8
  231. package/src/server/responses/terminal-guard.ts +2 -0
  232. package/src/server/responses/ws-upstream.ts +44 -4
  233. package/src/server/restart-replacement.ts +382 -0
  234. package/src/server/startup-health-cache.ts +23 -4
  235. package/src/server/stop-teardown.ts +35 -1
  236. package/src/server/system-env.ts +3 -0
  237. package/src/service/windows-taskxml.ts +11 -7
  238. package/src/service/windows-wrapper-exit.ts +8 -0
  239. package/src/stall-timeout.ts +35 -10
  240. package/src/storage/policy-job.ts +4 -0
  241. package/src/storage/scanner.ts +177 -29
  242. package/src/storage/storage-mutation-coordinator.ts +17 -0
  243. package/src/tray/windows-tray.ps1 +50 -23
  244. package/src/types/config.ts +4 -0
  245. package/src/types/provider.ts +5 -1
  246. package/src/types/request.ts +13 -2
  247. package/src/update/index.ts +3 -1
  248. package/src/update/job.ts +6 -2
  249. package/src/update/mise-launcher-target.ts +100 -0
  250. package/src/update/worker-launch.ts +46 -0
  251. package/src/usage/log.ts +2 -0
  252. package/src/web-search/loop.ts +5 -2
  253. package/gui/dist/assets/App-BJsT8Icc.css +0 -1
  254. package/gui/dist/assets/App-D3pNGiN4.js +0 -51
  255. package/gui/dist/assets/Tray-yb05wCjb.js +0 -1
  256. package/gui/dist/assets/index-CouvvtMV.js +0 -86
  257. package/gui/dist/assets/tray-data-CYGdjCJ7.js +0 -1
@@ -0,0 +1,60 @@
1
+ import { siblingOfLivePort } from "../../codex/sibling-start";
2
+ import { CODEX_RESTART_PATH } from "../../lib/codex-restart-contract";
3
+
4
+ /**
5
+ * Management mutations a sibling instance refuses (`src/codex/sibling-start.ts`).
6
+ *
7
+ * The owner-level gates stop the writers a sibling reaches on its own: startup sync, catalog
8
+ * convergence, owned-catalog refresh, the Claude roster, system env and the stop teardown. These
9
+ * routes reach shared client state through paths those gates do not cover, so the sibling's own
10
+ * management API refuses them outright: toggling or stripping the live owner's Codex, Grok or Claude
11
+ * integrations, rewriting client files at this port, the Codex prompt layers and `[features]`
12
+ * written into the shared `config.toml`, killing the user's Codex app-servers, joining a link (which
13
+ * writes the shared catalog and restarts as a client), staging or switching the physical native-main
14
+ * login, baking the machine service, PATH shim or tray from this home, replacing the global package
15
+ * (the live proxy drains on its package-tree refresh), Codex's own log database, and the
16
+ * archived-session cleanup, cleanup-policy run and trash restore that rewrite the shared CODEX_HOME.
17
+ *
18
+ * Everything else stays open: every read, `POST /api/stop`, `POST /api/system/restart`, and the
19
+ * own-home mutations (providers, keys, settings). `PUT /api/settings` is allowed because it carries
20
+ * own-home settings; its shared fan-out is gated in `syncEnabledClientIntegrations`. So is
21
+ * `PUT /api/storage/cleanup-policy`; the scheduled runs it enables stand down in
22
+ * `maybeRequestStorageCleanupPolicyRun` (`src/storage/policy-job.ts`).
23
+ *
24
+ * Paths are compared through this table and a variable, never as a quoted pathname equality:
25
+ * `tests/helpers/management-route-scan.ts` reads that form as a route declaration, and this file
26
+ * declares no route of its own.
27
+ */
28
+ export const SIBLING_REFUSED_MANAGEMENT_PATHS: readonly { readonly path: string; readonly children: boolean }[] = [
29
+ { path: "/api/sync", children: false },
30
+ { path: "/api/client-integrations", children: true },
31
+ { path: "/api/native-integrations", children: true },
32
+ { path: "/api/claude-desktop", children: true },
33
+ { path: "/api/claude-code", children: false },
34
+ { path: "/api/grok/apply", children: false },
35
+ { path: "/api/grok/selection", children: false },
36
+ { path: "/api/codex-prompt", children: true },
37
+ { path: CODEX_RESTART_PATH, children: false },
38
+ { path: "/api/link/join", children: false },
39
+ { path: "/api/native-main-profiles", children: true },
40
+ { path: "/api/codex-auth/main", children: true },
41
+ { path: "/api/startup-action", children: false },
42
+ { path: "/api/windows-tray", children: false },
43
+ { path: "/api/update/run", children: false },
44
+ { path: "/api/v2", children: false },
45
+ { path: "/api/codex-auth/features", children: true },
46
+ { path: "/api/storage/codex-logs", children: true },
47
+ // Archived-session cleanup, its policy run and trash restore rewrite the shared CODEX_HOME.
48
+ { path: "/api/storage/cleanup", children: false },
49
+ { path: "/api/storage/cleanup-policy/run", children: false },
50
+ { path: "/api/storage/trash/restore", children: false },
51
+ ];
52
+
53
+ const READ_METHODS: ReadonlySet<string> = new Set(["GET", "HEAD"]);
54
+
55
+ /** True when this process is a sibling and the request would mutate shared client state. */
56
+ export function siblingRefusesManagementRequest(method: string, pathname: string): boolean {
57
+ if (siblingOfLivePort() === null || READ_METHODS.has(method.toUpperCase())) return false;
58
+ return SIBLING_REFUSED_MANAGEMENT_PATHS.some(entry =>
59
+ pathname === entry.path || (entry.children && pathname.startsWith(`${entry.path}/`)));
60
+ }
@@ -11,7 +11,8 @@ import {
11
11
  type CodexLogGuardMutationResult,
12
12
  type CodexLogGuardStatus,
13
13
  } from "../../codex/log-guard/protection";
14
- import { scanStorage } from "../../storage/scanner";
14
+ import { scanStorageAsync, type StorageReport } from "../../storage/scanner";
15
+ import { noteStorageMutationCompleted, storageMutationEpoch } from "../../storage/storage-mutation-coordinator";
15
16
  import { jsonResponse } from "../auth-cors";
16
17
  import {
17
18
  managementBodyTooLargeResponse,
@@ -104,6 +105,67 @@ async function readProtectMode(ctx: ManagementContext): Promise<"compat" | "quie
104
105
  return mode;
105
106
  }
106
107
 
108
+ /**
109
+ * Concurrent GET /api/storage requests for the same CODEX_HOME share one walk; nothing is
110
+ * cached past it. A walk is joined only while no storage mutation has completed since it
111
+ * started, so a read issued after a cleanup, restore, policy run or Log Guard change always
112
+ * rescans instead of receiving sizes observed before it.
113
+ */
114
+ interface StorageScanFlight {
115
+ epoch: number;
116
+ promise: Promise<StorageReport>;
117
+ }
118
+ const storageScanFlights = new Map<string, StorageScanFlight>();
119
+
120
+ export function sharedStorageScan(
121
+ codexHome: string,
122
+ scan: (codexHome: string) => Promise<StorageReport> = scanStorageAsync,
123
+ ): Promise<StorageReport> {
124
+ const epoch = storageMutationEpoch();
125
+ const pending = storageScanFlights.get(codexHome);
126
+ if (pending && pending.epoch === epoch) return pending.promise;
127
+ const flight: StorageScanFlight = {
128
+ epoch,
129
+ promise: scan(codexHome).finally(() => {
130
+ // A newer flight may already own this key; only retire our own entry.
131
+ if (storageScanFlights.get(codexHome) === flight) storageScanFlights.delete(codexHome);
132
+ }),
133
+ };
134
+ storageScanFlights.set(codexHome, flight);
135
+ return flight.promise;
136
+ }
137
+
138
+ /**
139
+ * Log Guard results that are refused before anything under CODEX_HOME is touched. Anything
140
+ * else (success, a database or config write failure, `busy` which compaction can report
141
+ * after partial progress, a post-compaction integrity failure) may have changed storage.
142
+ */
143
+ const REFUSED_BEFORE_CHANGE: ReadonlySet<string> = new Set([
144
+ "unsupported_schema",
145
+ "codex_running",
146
+ "process_enumeration_failed",
147
+ "unsafe_path",
148
+ "trigger_collision",
149
+ "auto_vacuum_not_incremental",
150
+ ]);
151
+
152
+ export function logGuardResultMayHaveChangedStorage(
153
+ result: CodexLogGuardMutationResult | CodexLogGuardCompactionResult,
154
+ ): boolean {
155
+ if (result.ok) return true;
156
+ if (result.error === "integrity_check_failed") return result.phase === "after";
157
+ return !REFUSED_BEFORE_CHANGE.has(result.error);
158
+ }
159
+
160
+ /** Invalidate in-flight storage scans only when the Log Guard operation may have changed storage. */
161
+ function afterLogGuardMutation(
162
+ result: CodexLogGuardMutationResult | CodexLogGuardCompactionResult,
163
+ response: Response,
164
+ ): Response {
165
+ if (logGuardResultMayHaveChangedStorage(result)) noteStorageMutationCompleted();
166
+ return response;
167
+ }
168
+
107
169
  /** Codex Log Guard diagnostics plus explicit protection and maintenance mutations. */
108
170
  export async function handleStorageLogGuardRoutes(ctx: ManagementContext): Promise<Response | null> {
109
171
  const { req, url, config, deps } = ctx;
@@ -129,22 +191,26 @@ export async function handleStorageLogGuardRoutes(ctx: ManagementContext): Promi
129
191
  if (req.method !== "POST") return null;
130
192
  const mode = await readProtectMode(ctx);
131
193
  if (mode instanceof Response) return mode;
132
- return mutationResponse(protectCodexLogs(mode, protectionDeps), ctx);
194
+ const result = protectCodexLogs(mode, protectionDeps);
195
+ return afterLogGuardMutation(result, mutationResponse(result, ctx));
133
196
  }
134
197
 
135
198
  if (url.pathname === "/api/storage/codex-logs/unprotect") {
136
199
  if (req.method !== "POST") return null;
137
- return mutationResponse(unprotectCodexLogs(protectionDeps), ctx);
200
+ const result = unprotectCodexLogs(protectionDeps);
201
+ return afterLogGuardMutation(result, mutationResponse(result, ctx));
138
202
  }
139
203
 
140
204
  if (url.pathname === "/api/storage/codex-logs/repair") {
141
205
  if (req.method !== "POST") return null;
142
- return mutationResponse(repairCodexLogGuardProtection(protectionDeps), ctx);
206
+ const result = repairCodexLogGuardProtection(protectionDeps);
207
+ return afterLogGuardMutation(result, mutationResponse(result, ctx));
143
208
  }
144
209
 
145
210
  if (url.pathname === "/api/storage/codex-logs/compact") {
146
211
  if (req.method !== "POST") return null;
147
- return compactResponse(compactCodexLogs(deps.codexLogGuardMaintenanceDeps), ctx);
212
+ const result = compactCodexLogs(deps.codexLogGuardMaintenanceDeps);
213
+ return afterLogGuardMutation(result, compactResponse(result, ctx));
148
214
  }
149
215
 
150
216
  if (url.pathname !== "/api/storage" || req.method !== "GET") return null;
@@ -154,7 +220,7 @@ export async function handleStorageLogGuardRoutes(ctx: ManagementContext): Promi
154
220
  // silently folded into CODEX_HOME totals.
155
221
  let storage;
156
222
  try {
157
- storage = scanStorage();
223
+ storage = await sharedStorageScan(resolveCodexHomeDir());
158
224
  } catch {
159
225
  const fallback = {
160
226
  codexHome: resolveCodexHomeDir(),
@@ -14,14 +14,28 @@
14
14
  * Installed-but-stale/missing service assets are NOT treated as supervised —
15
15
  * exit(1) would leave the proxy dead with `Service: installed, stale or missing
16
16
  * service assets` and a /healthz timeout.
17
- * - If detached spawn fails (sync throw or pre-start `error`): exit(1) without
18
- * markRecycling — after drain the listen socket is already closed, so a latch
19
- * reset cannot recover serving. Clear inherited `OCX_SERVICE` so exit cleanup
20
- * can restore Codex/Grok fences when a stale service marker has no viable
21
- * supervisor. Log only a stable errno code — never the raw message
17
+ * - The replacement is spawned by `src/server/restart-replacement.ts`: it carries
18
+ * `OCX_RESTART_PARENT_PID` and its output goes to the bounded `restart-handoff.log`.
19
+ * Only after a completed drain (the handoff waits for health) is a replacement that
20
+ * exits before it answers retried, twice, inside the readiness budget. A deadline,
21
+ * failed-drain or listener-stop-fallback handoff resolves on spawn and exits: the
22
+ * replacement's own parent wait and port reclaim cover it, and any other early exit
23
+ * there is not retried.
24
+ * - If detached spawn fails (sync throw, pre-start `error`, or every attempt exited
25
+ * early): exit(1) without markRecycling — after drain the listen socket is already
26
+ * closed, so a latch reset cannot recover serving. Clear inherited `OCX_SERVICE`
27
+ * so exit cleanup can restore Codex/Grok fences when a stale service marker has no
28
+ * viable supervisor. Log only a stable errno code — never the raw message
22
29
  * (paths in ENOENT often include the OS username).
30
+ * - Except after a committed client connection (a join into a Child): Codex already
31
+ * routes to the client runtime the next start serves on this port, so the failed
32
+ * handoff still marks recycling and exit cleanup keeps that routing instead of
33
+ * silently falling back to native Codex.
34
+ * - Desktop-supervised child (the desktop app spawned it with `OCX_DESKTOP_SUPERVISED=1`
35
+ * and is still its parent): no spawn. Mark recycle and exit 75; the app sees the exit
36
+ * and starts the replacement itself, so it keeps owning, stopping and quitting it.
37
+ * Checked before the service rule: that app, not a service manager, is the parent.
23
38
  */
24
- import { spawn } from "node:child_process";
25
39
  import {
26
40
  acquireTemporaryDrain,
27
41
  beginShutdownDrain,
@@ -34,25 +48,22 @@ import {
34
48
  stopServerListener,
35
49
  } from "../lifecycle";
36
50
  import { isServiceViable } from "../../service";
51
+ import { readClientConnectionState } from "../../client/state";
52
+ import { withSiblingMarker } from "../../codex/sibling-start";
53
+ import { issueSiblingHandoff } from "../../codex/sibling-handoff";
37
54
  import { readRuntimePort } from "../../config/process-state";
38
- import { withProcessRuntimeProvenance } from "../../lib/bun-runtime";
39
- import { selfLaunchArgv } from "../../lib/self-launch-argv";
40
55
  import { spendLedgerRestartEnvironment } from "../../lib/spend-ledger-owner";
41
56
  import {
57
+ DESKTOP_RESTART_EXIT_CODE,
58
+ DESKTOP_SUPERVISED_ENV,
42
59
  MEMORY_DRAIN_RESTART_MS,
43
- REPLACEMENT_READY_TIMEOUT_MS,
60
+ isDesktopSupervised,
44
61
  } from "../../lib/system-restart-contract";
45
- import { findLiveProxy } from "../proxy-liveness";
62
+ import { spawnReplacementStart } from "../restart-replacement";
46
63
 
47
64
  export { MEMORY_DRAIN_RESTART_MS, REPLACEMENT_READY_TIMEOUT_MS } from "../../lib/system-restart-contract";
65
+ export { waitForReplacementReady, type ReplacementReadinessIo } from "../restart-replacement";
48
66
  export const DEADLINE_LISTENER_STOP_TIMEOUT_MS = 5_000;
49
- const REPLACEMENT_READY_POLL_MS = 150;
50
-
51
- export interface ReplacementReadinessIo {
52
- findLive?: typeof findLiveProxy;
53
- now?: () => number;
54
- sleep?: (ms: number) => Promise<void>;
55
- }
56
67
 
57
68
  export interface SystemRestartIo {
58
69
  acquireTemporaryDrain?: () => { release(): void } | null;
@@ -60,11 +71,15 @@ export interface SystemRestartIo {
60
71
  /** True when a background service can actually respawn this process after exit(1). */
61
72
  isServiceViable?: () => boolean;
62
73
  isSupervisedServiceChild?: () => boolean;
74
+ /** True when the desktop app that spawned this process starts its replacement after exit 75. */
75
+ isDesktopSupervised?: () => boolean;
63
76
  /** Ordinary start; deadline handoff may defer health until parent exit releases OS locks. */
64
77
  spawnStart?: (port?: number, waitForHealthBeforeParentExit?: boolean) => void | Promise<void>;
65
78
  /** Idempotent listener close; must settle before an ordinary start is spawned. */
66
79
  stopListener?: () => void | Promise<void>;
67
80
  markRecycling?: () => void;
81
+ /** True once a client connection has committed (a join into a Child); read only after a failed handoff. */
82
+ isClientConnected?: () => boolean;
68
83
  exitProcess?: (code: number) => void;
69
84
  schedule?: (fn: () => void | Promise<void>, ms: number) => void;
70
85
  scheduleDeadline?: (fn: () => void, ms: number) => () => void;
@@ -161,7 +176,8 @@ export function setSystemRestartIoForTests(io: SystemRestartIo = {}): void {
161
176
  explicitShutdownRequested = false;
162
177
  }
163
178
 
164
- function resolveListenPort(): number | undefined {
179
+ /** The port this process is listening on, or undefined when that cannot be established. */
180
+ export function resolveListenPort(): number | undefined {
165
181
  const live = getServerListenPort();
166
182
  if (live) return live;
167
183
  const runtime = readRuntimePort(process.pid);
@@ -185,131 +201,67 @@ function spawnFailureCode(err: unknown): string {
185
201
  return "spawn_failed";
186
202
  }
187
203
 
188
- function handoffError(code: string): NodeJS.ErrnoException {
189
- const error = new Error(code) as NodeJS.ErrnoException;
190
- error.code = code;
191
- return error;
204
+ /**
205
+ * After a failed handoff, keep Codex routing when a client connection has committed.
206
+ *
207
+ * `connectClient` already pointed Codex at the client runtime that the next start (a service
208
+ * relaunch, the desktop app, or `ocx start`) serves on this same port. Restoring native Codex here
209
+ * would be a silent local fallback while client state says connected. An unreadable state is not
210
+ * proof of a join, so it keeps today's restore.
211
+ */
212
+ function keepRoutingForCommittedClient(io: SystemRestartIo): void {
213
+ let connected = false;
214
+ try {
215
+ connected = (io.isClientConnected ?? (() => readClientConnectionState().kind === "connected"))();
216
+ } catch {
217
+ connected = false;
218
+ }
219
+ if (connected) (io.markRecycling ?? markRecyclingForExit)();
192
220
  }
193
221
 
194
- export async function waitForReplacementReady(
195
- expectedPid: number | undefined,
196
- parentPid: number,
197
- expectedPort: number | undefined,
198
- io: ReplacementReadinessIo = {},
199
- ): Promise<boolean> {
200
- const findLive = io.findLive ?? findLiveProxy;
201
- const now = io.now ?? Date.now;
202
- const sleep = io.sleep ?? Bun.sleep;
203
- const deadline = now() + REPLACEMENT_READY_TIMEOUT_MS;
204
- while (now() < deadline) {
205
- try {
206
- const live = await findLive({
207
- deadlineAt: deadline,
208
- nowFn: now,
209
- sleepFn: sleep,
210
- });
211
- // A probe that began within budget can still return after it. Never accept
212
- // delayed health as proof once the shared absolute handoff budget expired.
213
- if (now() >= deadline) return false;
214
- if (
215
- live
216
- && live.pid !== null
217
- && live.pid !== parentPid
218
- && (expectedPid === undefined || live.pid === expectedPid)
219
- && (expectedPort === undefined || live.port === expectedPort)
220
- ) {
221
- return true;
222
- }
223
- } catch {
224
- // A not-yet-bound replacement is indistinguishable from a transient
225
- // liveness failure here; keep polling inside the one bounded window.
226
- }
227
- const remainingMs = deadline - now();
228
- if (remainingMs <= 0) break;
229
- await sleep(Math.min(REPLACEMENT_READY_POLL_MS, remainingMs));
230
- }
231
- return false;
222
+ /**
223
+ * Hand the restart to the desktop app that spawned this process: keep Codex routing for the
224
+ * replacement it starts on this port, and exit with the code it restarts on. False when nothing
225
+ * supervises this process that way, and then nothing happened.
226
+ */
227
+ function handOffToDesktop(io: SystemRestartIo, exitProcess: (code: number) => void): boolean {
228
+ if (!(io.isDesktopSupervised ?? isDesktopSupervised)()) return false;
229
+ (io.markRecycling ?? markRecyclingForExit)();
230
+ exitProcess(DESKTOP_RESTART_EXIT_CODE);
231
+ return true;
232
+ }
233
+
234
+ /**
235
+ * The replacement's environment before `spawnReplacementStart` adds the restart-parent marker and
236
+ * the runtime provenance: never under a service marker, a sibling's replacement stays a sibling,
237
+ * and only a parent-exit handoff marks the bounded spend-ledger lease wait.
238
+ */
239
+ export function replacementStartEnvironment(
240
+ waitForHealthBeforeParentExit: boolean,
241
+ parentPid: number = process.pid,
242
+ ): NodeJS.ProcessEnv {
243
+ // A sibling's replacement stays a sibling even if the owner is down while it probes.
244
+ const sourceEnv: NodeJS.ProcessEnv = withSiblingMarker(process.env, issueSiblingHandoff);
245
+ delete sourceEnv.OCX_SERVICE;
246
+ // A detached replacement is not the desktop app's child; it must never exit to an app that is not waiting on it.
247
+ delete sourceEnv[DESKTOP_SUPERVISED_ENV];
248
+ return spendLedgerRestartEnvironment(
249
+ sourceEnv,
250
+ waitForHealthBeforeParentExit ? undefined : parentPid,
251
+ );
232
252
  }
233
253
 
234
254
  function spawnDetachedStart(
235
255
  port?: number,
236
256
  waitForHealthBeforeParentExit = true,
237
257
  ): Promise<void> {
238
- const args = ["start"];
239
- const expectedPort = typeof port === "number" && Number.isFinite(port) && port > 0 && port <= 65535
240
- ? Math.trunc(port)
241
- : undefined;
242
- if (expectedPort !== undefined) {
243
- args.push("--port", String(expectedPort));
258
+ let env: NodeJS.ProcessEnv;
259
+ try {
260
+ env = replacementStartEnvironment(waitForHealthBeforeParentExit);
261
+ } catch (err) {
262
+ return Promise.reject(err);
244
263
  }
245
- const launchArgs = selfLaunchArgv(args);
246
- return new Promise<void>((resolve, reject) => {
247
- let child: ReturnType<typeof spawn>;
248
- try {
249
- const sourceEnv: NodeJS.ProcessEnv = { ...process.env };
250
- delete sourceEnv.OCX_SERVICE;
251
- const env = spendLedgerRestartEnvironment(
252
- sourceEnv,
253
- waitForHealthBeforeParentExit ? undefined : process.pid,
254
- );
255
- child = spawn(process.execPath, launchArgs, {
256
- detached: true,
257
- stdio: "ignore",
258
- windowsHide: true,
259
- env: withProcessRuntimeProvenance(env),
260
- });
261
- } catch (err) {
262
- reject(err);
263
- return;
264
- }
265
- let settled = false;
266
- const cleanup = () => {
267
- child.off("error", onError);
268
- child.off("exit", onExit);
269
- child.off("spawn", onSpawn);
270
- };
271
- const finish = (error?: unknown) => {
272
- if (settled) return;
273
- settled = true;
274
- cleanup();
275
- if (error !== undefined) {
276
- if (child.exitCode === null && child.signalCode === null) {
277
- try { child.kill(); } catch { /* best-effort failed-start cleanup */ }
278
- }
279
- try { child.unref(); } catch { /* best-effort */ }
280
- reject(error);
281
- return;
282
- }
283
- child.unref();
284
- resolve();
285
- };
286
- const onError = (err: Error) => { finish(err); };
287
- const onExit = () => { finish(handoffError("child_exit")); };
288
- const onSpawn = () => {
289
- if (!waitForHealthBeforeParentExit) {
290
- // A deadline may have been caused by native-main ownership cleanup.
291
- // Let the ordinary child survive parent exit, which releases those OS locks.
292
- finish();
293
- return;
294
- }
295
- void waitForReplacementReady(child.pid, process.pid, expectedPort).then(
296
- ready => {
297
- if (!ready) {
298
- console.warn(
299
- "Drain-and-restart replacement is still alive after the readiness window; allowing it to continue after parent exit",
300
- );
301
- }
302
- // Never kill a live ordinary start at its valid reclaim boundary. Parent
303
- // exit is the final resource release the child may still be waiting for.
304
- finish();
305
- },
306
- err => finish(err),
307
- );
308
- };
309
- child.once("error", onError);
310
- child.once("exit", onExit);
311
- child.once("spawn", onSpawn);
312
- });
264
+ return spawnReplacementStart({ port, waitForHealth: waitForHealthBeforeParentExit, env });
313
265
  }
314
266
 
315
267
  async function completeDeferredParentExitHandoff(
@@ -327,6 +279,7 @@ async function completeDeferredParentExitHandoff(
327
279
  `Drain-and-restart ${phase} spawn failed (${spawnFailureCode(err)}); exiting without replacement`,
328
280
  );
329
281
  delete process.env.OCX_SERVICE;
282
+ keepRoutingForCommittedClient(io);
330
283
  exitProcess(1);
331
284
  return;
332
285
  }
@@ -342,6 +295,7 @@ async function completeDeadlineRestartHandoff(
342
295
  canHandoff: () => boolean = () => true,
343
296
  ): Promise<void> {
344
297
  if (!canHandoff()) return;
298
+ if (handOffToDesktop(io, exitProcess)) return;
345
299
  const supervised = (io.isSupervisedServiceChild ?? (() => isSupervisedServiceChild(io)))();
346
300
  if (supervised) {
347
301
  // Failure-only supervisors ignore exit(0); intentional non-zero triggers respawn.
@@ -455,6 +409,7 @@ export function acceptSystemRestart(io: SystemRestartIo = restartIo, admission:
455
409
  // rejects, an accepted restart must still reach replacement or terminal exit.
456
410
  console.warn("Drain-and-restart cleanup failed; continuing terminal restart handoff");
457
411
  }
412
+ if (handOffToDesktop(io, exitProcess)) return;
458
413
  const supervised = (io.isSupervisedServiceChild ?? (() => isSupervisedServiceChild(io)))();
459
414
  if (supervised) {
460
415
  // Failure-only supervisors ignore exit(0); intentional non-zero triggers respawn.
@@ -487,8 +442,10 @@ export function acceptSystemRestart(io: SystemRestartIo = restartIo, admission:
487
442
  );
488
443
  // Listen socket is already stopped; do not markRecycling — no child to inherit fences.
489
444
  // No replacement inherited the routing. Clear a stale service marker so
490
- // this unsupervised parent restores clients after the failed handoff.
445
+ // this unsupervised parent restores clients after the failed handoff — unless a
446
+ // committed client connection already owns that routing.
491
447
  delete process.env.OCX_SERVICE;
448
+ keepRoutingForCommittedClient(io);
492
449
  exitProcess(1);
493
450
  return;
494
451
  }
@@ -217,7 +217,7 @@ export async function handleSystemRoutes(ctx: ManagementContext): Promise<Respon
217
217
  };
218
218
  }
219
219
  if (req.method === "GET") {
220
- return jsonResponse(service.readState(), 200, req, config);
220
+ return jsonResponse(await service.readState(), 200, req, config);
221
221
  }
222
222
  return jsonResponse(await service.performRestart(), 200, req, config);
223
223
  }
@@ -88,7 +88,11 @@ import type { CatalogDisposition, ConvergeCodex } from "../codex/convergence-typ
88
88
  import { normalizeCatalogDisposition } from "../codex/catalog-refresh-status";
89
89
  import { managementBodyTooLargeResponse } from "./management/body";
90
90
  import { handleSessionRoutes } from "./management/session-routes";
91
+ import { siblingRefusesManagementRequest } from "./management/sibling-guard";
92
+ import { siblingOfLivePort, siblingSkipMessage } from "../codex/sibling-start";
91
93
  import { packageVersion } from "../lib/package-version";
94
+ import { isLocalAccountSwitchPath } from "../lib/local-account-switch-capability";
95
+ import { readVerifiedAccountSwitchBody } from "./local-account-switch-auth";
92
96
 
93
97
  // installed npm version instead of a stale hardcode.
94
98
  const MANAGEMENT_VERSION_FALLBACK = "0.0.0";
@@ -213,6 +217,19 @@ export async function handleManagementAPI(
213
217
  if (!isAllowedManagementOrigin(req, config)) {
214
218
  return jsonResponse({ error: "cross-origin request blocked" }, 403, req, config);
215
219
  }
220
+ if (principal === "local-account-switch-capability") {
221
+ if (req.method !== "PUT" || !isLocalAccountSwitchPath(url.pathname) || url.search !== "") {
222
+ return jsonResponse({ error: "account switch capability scope mismatch" }, 403, req, config);
223
+ }
224
+ if (req.headers.has("content-encoding")) {
225
+ return jsonResponse({ error: "content encoding is not supported" }, 415, req, config);
226
+ }
227
+ const verified = await readVerifiedAccountSwitchBody(req);
228
+ if (verified.status !== 200) {
229
+ return jsonResponse({ error: "account switch body rejected" }, verified.status, req, config);
230
+ }
231
+ req = new Request(req.url, { method: req.method, headers: req.headers, body: Buffer.from(verified.body) });
232
+ }
216
233
  // Management bodies are small JSON (provider names, key ids, settings). Reject oversized
217
234
  // payloads before any handler buffers them — the data plane has its own decompression cap.
218
235
  if (req.method === "POST" || req.method === "PUT" || req.method === "PATCH") {
@@ -297,6 +314,10 @@ export async function handleManagementAPI(
297
314
  guiSessionIssuance: requestIngress.guiSessionIssuance ?? null,
298
315
  convergeCodexCatalog, syncClaudeAgentDefsBestEffort,
299
316
  };
317
+ // Before any route module, including the link, native-main and codex-auth dispatch below.
318
+ if (siblingRefusesManagementRequest(req.method, url.pathname)) {
319
+ return jsonResponse({ error: siblingSkipMessage(), code: "sibling_instance" }, 409, req, config);
320
+ }
300
321
  let routed: Response | null | undefined;
301
322
  try {
302
323
  routed = handleSessionRoutes(ctx)
@@ -364,9 +385,16 @@ export async function handleManagementAPI(
364
385
  // outcome. This process cannot verify its own post-exit respawn window; only the
365
386
  // receipt-backed parent `ocx stop` can, which is what the deferral exists for.
366
387
  const { deferralMatchesReceipt } = await import("../config/pending-teardown");
367
- const { deferralHonored, performStopTeardown } = await import("./stop-teardown");
388
+ const { deferralHonored, desktopSupervisedStopRefusal, performStopTeardown } = await import("./stop-teardown");
389
+ // The desktop app would start this proxy again within seconds; refuse before anything is
390
+ // touched and point at its tray, whose Stop it honours (#3008 refuses an undone stop the same way).
391
+ const desktopRefusal = desktopSupervisedStopRefusal(principal);
392
+ if (desktopRefusal) return jsonResponse(desktopRefusal, 409, req, config);
368
393
  const holdsReceipt = deferralHonored(url, deferralMatchesReceipt);
369
- const respawnRisk = holdsReceipt ? "none" : installedServiceRespawnRisk();
394
+ // A sibling never runs under a service manager, and the installed service is the live
395
+ // owner's: asking the manager to stop from here would refuse, or boot the owner's job out.
396
+ const sibling = siblingOfLivePort() !== null;
397
+ const respawnRisk = holdsReceipt || sibling ? "none" : installedServiceRespawnRisk();
370
398
  if (respawnRisk === "respawnable") {
371
399
  return jsonResponse({
372
400
  success: false,
@@ -400,7 +428,7 @@ export async function handleManagementAPI(
400
428
  }
401
429
  let serviceStop: import("../service").ServiceStopOutcome;
402
430
  try {
403
- serviceStop = stopServiceIfInstalledDetailed();
431
+ serviceStop = sibling ? "absent" : stopServiceIfInstalledDetailed();
404
432
  } catch (err) {
405
433
  if (isServiceOwnershipError(err)) {
406
434
  // The installed service belongs to another CODEX_HOME/OPENCODEX_HOME: it would respawn
@@ -72,6 +72,7 @@ import {
72
72
  type GuiSessionRequestContext,
73
73
  } from "./gui-session";
74
74
  import { hasLocalDesktopSnapshotCapability } from "./local-desktop-snapshot-auth";
75
+ import { hasLocalAccountSwitchCapability } from "./local-account-switch-auth";
75
76
  export type { GuiSessionBootstrap, GuiSessionRequestContext } from "./gui-session";
76
77
 
77
78
  const LOCAL_READ_REPLAY_LIMIT = 256;
@@ -337,6 +338,7 @@ export type ManagementPrincipal =
337
338
  | "gui-pair-capability"
338
339
  | "local-read-capability"
339
340
  | "local-desktop-snapshot-capability"
341
+ | "local-account-switch-capability"
340
342
  | "local-provider-reload-capability"
341
343
  | "local-aside-sync-capability"
342
344
  | "system-restart-capability";
@@ -564,6 +566,7 @@ function resolveManagementAdmission(
564
566
  else if (hasLocalAsideSyncCapability(req, local)) principal = "local-aside-sync-capability";
565
567
  else if (hasLocalProviderReloadCapability(req, local)) principal = "local-provider-reload-capability";
566
568
  else if (hasLocalDesktopSnapshotCapability(req, local)) principal = "local-desktop-snapshot-capability";
569
+ else if (hasLocalAccountSwitchCapability(req, local)) principal = "local-account-switch-capability";
567
570
  else if (hasLocalReadCapability(req, local)) principal = "local-read-capability";
568
571
  else if (hasGuiPairCapability(req, local)) principal = "gui-pair-capability";
569
572
  else if (state.available) {