@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
@@ -3,6 +3,7 @@ import { diagnoseCodexShim, installCodexShim, uninstallCodexShim } from "../code
3
3
  import { readManagementJsonBody } from "../server/management/body";
4
4
  import type { OcxClientConnectionConfig } from "../types";
5
5
  import { disconnectClient, syncConnectedClient } from "./connect";
6
+ import { isLinkConnection } from "./state";
6
7
 
7
8
  export type HubReachability = "unknown" | "online" | "offline" | "unauthorized";
8
9
 
@@ -50,14 +51,19 @@ async function jsonBody(req: Request): Promise<unknown | Response> {
50
51
 
51
52
  function statusPayload(req: Request, state: OcxClientConnectionConfig, deps: MachineApiDeps): MachineStatusV1 {
52
53
  const machineBase = new URL(req.url).origin;
54
+ // A link Child's management URL is the tunnel, whose hub-link ingress serves no /api/* and no
55
+ // session bootstrap. Its dashboard reads the Child's own listener instead (`/api/link/status`).
56
+ const linkMode = isLinkConnection(state);
53
57
  return {
54
58
  mode: "client",
55
59
  connected: true,
56
60
  machineBase,
57
- sharedBase: state.managementTransport === "relay"
58
- ? `${machineBase}/api/machine/hub-relay`
59
- : state.managementUrl,
60
- sharedServerOrigin: state.managementUrl,
61
+ sharedBase: linkMode
62
+ ? machineBase
63
+ : state.managementTransport === "relay"
64
+ ? `${machineBase}/api/machine/hub-relay`
65
+ : state.managementUrl,
66
+ sharedServerOrigin: linkMode ? machineBase : state.managementUrl,
61
67
  managementTransport: state.managementTransport,
62
68
  apiKeyId: state.apiKeyId,
63
69
  protocolVersion: state.protocolVersion,
@@ -1,6 +1,6 @@
1
1
  import type { Server } from "bun";
2
2
  import { loadConfig } from "../config";
3
- import { browserSecurityHeaders } from "../server/auth-cors";
3
+ import { browserSecurityHeaders, requestPolicyView } from "../server/auth-cors";
4
4
  import { serveGuiFile, serveSessionBootstrap } from "../server/gui-static";
5
5
  import {
6
6
  initializeManagementAuthState,
@@ -9,13 +9,18 @@ import {
9
9
  requireManagementAuth,
10
10
  type ManagementAuthState,
11
11
  } from "../server/management-auth";
12
+ import { resolveInboundBodyLimitBytes } from "../server/request-decompress";
12
13
  import type { OcxClientConnectionConfig, OcxConfig } from "../types";
13
14
  import { disconnectClient, syncConnectedClient } from "./connect";
14
15
  import { isLinkConnection, readClientConnectionState } from "./state";
15
16
  import { handleMachineApi, type HubReachability, type MachineApiDeps } from "./machine-api";
16
17
  import { MACHINE_GUI_ORIGIN_HEADER, requireMachineAuth } from "./machine-auth";
17
18
  import { HUB_RELAY_REQUEST_BODY_MAX_BYTES, relayHubManagementRequest } from "./hub-relay";
18
- import { relayLinkDataRequest } from "./link-relay";
19
+ import { createLinkKeySource, handleLinkIngress, type LinkIngress, type LinkKeySourceDeps } from "./link-ingress";
20
+ import type { LinkTunnelGate } from "./link-relay";
21
+ import { readClientLinkState, type ClientLinkState } from "./link-state";
22
+ import { projectClientLinkChild, type ClientLinkSidecarRead } from "./link-status";
23
+ import type { ClientLinkSupervisorStatus } from "./link-tunnel";
19
24
  import { packageVersion } from "../lib/package-version";
20
25
  import { linkRouteAllowed } from "../link/routes";
21
26
 
@@ -24,12 +29,27 @@ const GUI_SPA_PATHS = new Set([
24
29
  "/dashboard", "/startup", "/providers", "/models", "/subagents",
25
30
  "/logs", "/usage", "/storage", "/codex-set", "/integrations",
26
31
  ]);
32
+ /** The standalone listener's idle limit: long generations and held turns are never cut. */
33
+ const LINK_LISTENER_IDLE_TIMEOUT_SECONDS = 255;
27
34
 
28
35
  export interface MachineListenerDeps {
29
36
  state?: OcxClientConnectionConfig;
30
37
  managementAuthState?: ManagementAuthState;
31
38
  fetchImpl?: typeof fetch;
32
39
  machineApi?: Partial<MachineApiDeps>;
40
+ /** Link mode: the client tunnel supervisor state for `GET /api/link/status`. */
41
+ linkStatus?: () => ClientLinkSupervisorStatus;
42
+ /** Link mode: the sidecar read for `GET /api/link/status`. */
43
+ readSidecar?: () => ClientLinkState | null;
44
+ /** Link mode: how the link key is read; the listener reads it once and caches it. */
45
+ linkKey?: LinkKeySourceDeps;
46
+ /** Link mode: a key source the runtime already holds (shared with the tunnel supervisor). */
47
+ linkKeySource?: () => string | null;
48
+ /** Link mode: the tunnel supervisor; relayed requests wait on it while it reconnects. */
49
+ linkTunnel?: LinkTunnelGate;
50
+ /** Link mode: relay seams (deadline clock and byte cap). */
51
+ linkRelay?: LinkIngress["relay"];
52
+ serve?: (options: Parameters<typeof Bun.serve>[0]) => Server<unknown>;
33
53
  }
34
54
 
35
55
  function json404(req: Request): Response {
@@ -47,6 +67,8 @@ export function machineRouteAllowed(url: URL, req: Request, relayEnabled: boolea
47
67
  const path = url.pathname;
48
68
  if (req.method === "GET" && (path === "/healthz" || path === "/readyz" || path === "/" || path === "/opencodex-session")) return true;
49
69
  if ((req.method === "GET" || req.method === "HEAD") && (path === "/api/machine/status" || path === "/api/machine/clients" || path === "/api/machine/shim")) return true;
70
+ // The one link route a connected Child serves: its own read-only link status.
71
+ if (linkMode && (req.method === "GET" || req.method === "HEAD") && path === "/api/link/status") return true;
50
72
  if (req.method === "POST" && (path === "/api/machine/sync" || path === "/api/machine/shim" || path === "/api/machine/disconnect")) return true;
51
73
  if (relayEnabled && path.startsWith("/api/machine/hub-relay/")) return true;
52
74
  // Known machine endpoints are admitted for every method so an unsupported
@@ -60,6 +82,10 @@ export function machineRouteAllowed(url: URL, req: Request, relayEnabled: boolea
60
82
  || /\.(?:css|gif|ico|jpe?g|js|json|map|png|svg|webp|woff2?)$/i.test(path);
61
83
  }
62
84
 
85
+ function readSidecarSafely(read: () => ClientLinkState | null): ClientLinkSidecarRead {
86
+ try { return read(); } catch { return "invalid"; }
87
+ }
88
+
63
89
  export function startMachineListener(
64
90
  port?: number,
65
91
  deps: MachineListenerDeps = {},
@@ -84,17 +110,33 @@ export function startMachineListener(
84
110
  setHubReachability: deps.machineApi?.setHubReachability ?? (value => { hubReachability = value; }),
85
111
  };
86
112
  const relayEnabled = !linkMode && connection.managementTransport === "relay";
113
+ // Link mode only. Everything is resolved once here, so a relayed request reads no file and
114
+ // builds no policy: the key is cached, the loopback policy is fixed at bind like the listener.
115
+ const inboundBodyLimit = resolveInboundBodyLimitBytes(config.maxInboundBodyBytes);
116
+ const linkIngress: LinkIngress | null = linkMode
117
+ ? {
118
+ tunnelPort: connection.link!.tunnelPort,
119
+ policy: requestPolicyView(config, "127.0.0.1"),
120
+ linkKey: deps.linkKeySource ?? createLinkKeySource(connection.tokenFingerprint, deps.linkKey),
121
+ relay: { fetchImpl: deps.fetchImpl, bodyLimitBytes: inboundBodyLimit, tunnel: deps.linkTunnel, ...deps.linkRelay },
122
+ }
123
+ : null;
124
+ const readSidecar = deps.readSidecar ?? (() => readClientLinkState());
87
125
 
88
- return Bun.serve({
126
+ return (deps.serve ?? (options => Bun.serve(options)))({
89
127
  port: port ?? config.port ?? 10100,
90
128
  hostname: "127.0.0.1",
91
- maxRequestBodySize: HUB_RELAY_REQUEST_BODY_MAX_BYTES,
92
- async fetch(req, server) {
129
+ // A hub client relays only bounded management calls. A link Child carries the Codex data
130
+ // plane, so it admits what a standalone listener admits and keeps its idle limit.
131
+ maxRequestBodySize: linkMode ? inboundBodyLimit : HUB_RELAY_REQUEST_BODY_MAX_BYTES,
132
+ ...(linkMode ? { idleTimeout: LINK_LISTENER_IDLE_TIMEOUT_SECONDS } : {}),
133
+ async fetch(req: Request, server: Server<unknown>) {
93
134
  const url = new URL(req.url);
94
- if (!machineRouteAllowed(url, req, relayEnabled, linkMode)) return json404(req);
95
- if (linkMode && linkRouteAllowed(url, req)) {
96
- return relayLinkDataRequest(req, { tunnelPort: connection.link!.tunnelPort }, { fetchImpl: deps.fetchImpl });
135
+ if (linkIngress) {
136
+ const handled = handleLinkIngress(req, url, linkIngress, server);
137
+ if (handled) return handled;
97
138
  }
139
+ if (!machineRouteAllowed(url, req, relayEnabled, linkMode)) return json404(req);
98
140
  if (url.pathname === "/healthz" && req.method === "GET") {
99
141
  return Response.json({ service: "opencodex", version: VERSION, role: "client", uptime: process.uptime(), pid: process.pid, port: server.port });
100
142
  }
@@ -116,7 +158,7 @@ export function startMachineListener(
116
158
  else hubReachability = "online";
117
159
  return response;
118
160
  }
119
- if (url.pathname.startsWith("/api/machine/")) {
161
+ if (url.pathname.startsWith("/api/machine/") || (linkMode && url.pathname === "/api/link/status")) {
120
162
  const authError = requireManagementAuth(req, managementAuth, config);
121
163
  if (authError) return authError;
122
164
  if (managementPrincipal(req, managementAuth, config) !== "gui-session") {
@@ -130,6 +172,11 @@ export function startMachineListener(
130
172
  if (req.method !== "GET" && req.method !== "HEAD") {
131
173
  return Response.json({ error: "opencodex machine changes require the local CLI" }, { status: 403 });
132
174
  }
175
+ if (url.pathname === "/api/link/status") {
176
+ // A Child never joins again from here, so the dashboard reads `joinAvailable: false`.
177
+ const status = projectClientLinkChild(readSidecarSafely(readSidecar), deps.linkStatus?.() ?? { kind: "stopped" }, Date.now());
178
+ return Response.json({ ...status, joinAvailable: false }, { headers: { "Cache-Control": "no-store" } });
179
+ }
133
180
  return await handleMachineApi(req, url, connection, machineApiDeps) ?? json404(req);
134
181
  }
135
182
 
@@ -158,5 +205,5 @@ export function startMachineListener(
158
205
  }
159
206
  return json404(req);
160
207
  },
161
- });
208
+ } as Parameters<typeof Bun.serve>[0]);
162
209
  }
@@ -1,15 +1,26 @@
1
- import { spawn } from "node:child_process";
2
1
  import { existsSync } from "node:fs";
3
2
  import type { Server } from "bun";
3
+ import { siblingRuntimeField, withSiblingMarker } from "../codex/sibling-start";
4
+ import { issueSiblingHandoff } from "../codex/sibling-handoff";
4
5
  import { loadConfig } from "../config";
5
- import { removePid, removeRuntimePort, writePid, writeRuntimePort } from "../config/process-state";
6
+ import { removePid, removeRuntimePort, writePid, writeRuntimePort, type RuntimePortState } from "../config/process-state";
6
7
  import { installCrashGuards } from "../lib/crash-guard";
7
- import { selfLaunchArgv } from "../lib/self-launch-argv";
8
+ import { createLocalAttestationSecret } from "../lib/local-management-attestation";
8
9
  import { loadServiceTokenFromFile, serviceApiTokenFingerprint } from "../lib/service-secrets";
9
- import { findAvailablePort, PortUnavailableError } from "../server/ports";
10
+ import {
11
+ DESKTOP_RESTART_EXIT_CODE,
12
+ DESKTOP_SUPERVISED_ENV,
13
+ DESKTOP_SUPERVISED_PORT_WAIT_MS,
14
+ isDesktopSupervised,
15
+ } from "../lib/system-restart-contract";
16
+ import { findAvailablePort, isAddrInUse, PortUnavailableError, waitForPortAvailable } from "../server/ports";
17
+ import type { ReplacementStartRequest } from "../server/restart-replacement";
18
+ import type { OcxClientConnectionConfig } from "../types";
19
+ import { createLinkKeySource } from "./link-ingress";
20
+ import { HOME_INITIATED_LINK_TUNNEL } from "./link-relay";
10
21
  import { createClientLinkSupervisor, type ClientLinkSupervisor } from "./link-tunnel";
11
- import { clientLinkStatePath } from "./link-state";
12
- import { startMachineListener } from "./machine-listener";
22
+ import { clientLinkStatePath, isChildInitiatedLink, recordChildInitiatedLink } from "./link-state";
23
+ import { startMachineListener, type MachineListenerDeps } from "./machine-listener";
13
24
  import { isLinkConnection, readClientConnectionState } from "./state";
14
25
 
15
26
  let activeServer: Server<unknown> | null = null;
@@ -17,16 +28,64 @@ let activePort: number | null = null;
17
28
  let activeSupervisor: ClientLinkSupervisor | null = null;
18
29
  let recycleScheduled = false;
19
30
 
31
+ /**
32
+ * How long link mode waits for its configured port: the budget a hard-pinned `ocx start --port`
33
+ * gives `reclaimListenPort` (`src/cli/index.ts`).
34
+ */
35
+ export const LINK_PORT_WAIT_MS = 60_000;
36
+
37
+ /** How long a pinned port is re-probed after the reclaim wait before the start gives up on it. */
38
+ export const PINNED_PREFER_RETRY_MS = 5_000;
39
+
40
+ /**
41
+ * Link mode's port-reclaim budget. Under the desktop app the whole wait (this plus
42
+ * {@link PINNED_PREFER_RETRY_MS}) ends inside the app's 30-second startup deadline, so the app sees
43
+ * this start either serve or exit instead of giving up on it first.
44
+ */
45
+ export function linkPortWaitMs(desktopSupervised: boolean = isDesktopSupervised()): number {
46
+ return desktopSupervised ? DESKTOP_SUPERVISED_PORT_WAIT_MS : LINK_PORT_WAIT_MS;
47
+ }
48
+ /** Bind attempts when the port is taken between the free-port probe and `Bun.serve`. */
49
+ const LINK_BIND_ATTEMPTS = 3;
50
+
51
+ export interface ClientRuntimeIo {
52
+ /** Link-mode port budget; defaults to {@link LINK_PORT_WAIT_MS}. */
53
+ portWaitMs?: number;
54
+ startListener?: typeof startMachineListener;
55
+ }
56
+
57
+ export interface StandaloneRecycleIo {
58
+ spawnReplacement?: (request: ReplacementStartRequest) => Promise<void>;
59
+ exitProcess?: (code: number) => void;
60
+ configuredPort?: () => number | undefined;
61
+ isDesktopSupervised?: () => boolean;
62
+ }
63
+
20
64
  function cleanup(): void {
21
65
  removePid(process.pid);
22
66
  removeRuntimePort(process.pid);
23
67
  }
24
68
 
69
+ /**
70
+ * What this runtime publishes in `runtime-port.json`. The attestation secret is what the desktop app
71
+ * reads to authenticate the runtime it started (`desktop/src-tauri/src/auth.rs`); a record without one
72
+ * reads as unusable there, the same as a standalone start's record would.
73
+ */
74
+ export function clientRuntimeRecord(
75
+ pid: number,
76
+ port: number,
77
+ attestationSecret: string = createLocalAttestationSecret(),
78
+ ): RuntimePortState {
79
+ return { pid, port, hostname: "127.0.0.1", attestationSecret, ...siblingRuntimeField() };
80
+ }
81
+
25
82
  export function standaloneRecycleEnv(
26
83
  env: NodeJS.ProcessEnv,
27
84
  disconnectedTokenFingerprint: string,
28
85
  ): NodeJS.ProcessEnv {
29
86
  const childEnv = { ...env };
87
+ // A detached replacement is not the desktop app's child.
88
+ delete childEnv[DESKTOP_SUPERVISED_ENV];
30
89
  const admissionToken = childEnv.OPENCODEX_API_AUTH_TOKEN?.trim();
31
90
  if (admissionToken && serviceApiTokenFingerprint(admissionToken) !== disconnectedTokenFingerprint) {
32
91
  // A surviving env token shadows OCX_API_TOKEN_FILE entirely, so nothing below can
@@ -54,8 +113,31 @@ export function scheduleStandaloneRecycle(disconnectedTokenFingerprint: string):
54
113
  if (typeof timer === "object" && "unref" in timer) timer.unref();
55
114
  }
56
115
 
57
- async function recycleStandalone(disconnectedTokenFingerprint: string): Promise<void> {
58
- const port = activePort;
116
+ function configuredTcpPort(): number | undefined {
117
+ try {
118
+ const port = loadConfig().port;
119
+ return Number.isInteger(port) && port >= 1 && port <= 65535 ? port : undefined;
120
+ } catch {
121
+ return undefined;
122
+ }
123
+ }
124
+
125
+ function errorCode(error: unknown): string {
126
+ const code = error && typeof error === "object" && "code" in error ? (error as { code?: unknown }).code : undefined;
127
+ return typeof code === "string" && /^[A-Za-z0-9_]{1,64}$/.test(code) ? code : "failed";
128
+ }
129
+
130
+ export async function recycleStandalone(
131
+ disconnectedTokenFingerprint: string,
132
+ io: StandaloneRecycleIo = {},
133
+ ): Promise<void> {
134
+ // The configured port when the listener never recorded one: the recycle still owes a proxy.
135
+ const port = activePort ?? (io.configuredPort ?? configuredTcpPort)();
136
+ const exit = io.exitProcess ?? ((code: number) => { process.exit(code); });
137
+ // Capture the one-use sibling handoff before this process removes its runtime record.
138
+ const replacementEnv = port && process.env.OCX_SERVICE !== "1"
139
+ ? withSiblingMarker(standaloneRecycleEnv(process.env, disconnectedTokenFingerprint), issueSiblingHandoff)
140
+ : null;
59
141
  try {
60
142
  await activeSupervisor?.stop();
61
143
  } catch (error) {
@@ -69,8 +151,14 @@ async function recycleStandalone(disconnectedTokenFingerprint: string): Promise<
69
151
  }
70
152
  cleanup();
71
153
  // Recycling back to standalone after `ocx disconnect` must actually bring a standalone
72
- // proxy back, under either launch shape.
154
+ // proxy back, under every launch shape.
73
155
  //
156
+ // Under the desktop app that spawned us: exit 75 and let the app start the standalone
157
+ // runtime, so it keeps owning it (tray Stop, Quit) instead of losing it to a detached child.
158
+ if ((io.isDesktopSupervised ?? isDesktopSupervised)()) {
159
+ exit(DESKTOP_RESTART_EXIT_CODE);
160
+ return;
161
+ }
74
162
  // Unsupervised: spawn the replacement ourselves and exit 0.
75
163
  //
76
164
  // Supervised (`OCX_SERVICE=1`): do NOT spawn — the supervisor owns the process, and a
@@ -83,22 +171,103 @@ async function recycleStandalone(disconnectedTokenFingerprint: string): Promise<
83
171
  //
84
172
  // launchd's KeepAlive restarts on any exit, so it is correct under both branches.
85
173
  if (process.env.OCX_SERVICE === "1") {
86
- process.exit(1);
174
+ exit(1);
175
+ return;
176
+ }
177
+ if (port === undefined) {
178
+ console.warn("[client] no valid port to restart the standalone proxy on; run 'ocx start'");
179
+ exit(1);
180
+ return;
87
181
  }
88
- if (port) {
89
- const child = spawn(process.execPath, selfLaunchArgv(["start", "--port", String(port)]), {
90
- detached: true,
91
- stdio: "ignore",
92
- windowsHide: true,
93
- env: standaloneRecycleEnv(process.env, disconnectedTokenFingerprint),
182
+ if (!replacementEnv) throw new Error("Standalone recycle has no replacement environment.");
183
+ // Wait until the replacement answers (it retries an early exit) instead of exiting the moment
184
+ // it spawned: a replacement that died unseen left no proxy and nothing to report it.
185
+ try {
186
+ const spawnReplacement = io.spawnReplacement
187
+ ?? (async (request: ReplacementStartRequest) => (await import("../server/restart-replacement")).spawnReplacementStart(request));
188
+ await spawnReplacement({
189
+ port,
190
+ waitForHealth: true,
191
+ // A sibling's replacement stays a sibling even if the owner is down while it probes.
192
+ env: replacementEnv,
94
193
  });
95
- child.unref();
194
+ } catch (error) {
195
+ console.warn(`[client] the standalone replacement did not start (${errorCode(error)}); run 'ocx start'`);
196
+ exit(1);
197
+ return;
198
+ }
199
+ exit(0);
200
+ }
201
+
202
+ /**
203
+ * Bind the client listener. Link mode is pinned to the configured port because Codex routes to it:
204
+ * like a hard-pinned `ocx start --port`, it waits for a port that a restarting parent is still
205
+ * releasing, never kills the holder and never hops. The waits run only while the port is busy; a
206
+ * free port binds on the first probe.
207
+ */
208
+ export async function bindClientListener(
209
+ request: {
210
+ state: OcxClientConnectionConfig;
211
+ linkMode: boolean;
212
+ preferred: number;
213
+ explicitPort: boolean;
214
+ configuredPort: number;
215
+ } & Pick<MachineListenerDeps, "linkStatus" | "linkKeySource" | "linkTunnel">,
216
+ io: ClientRuntimeIo = {},
217
+ ): Promise<{ server: Server<unknown>; port: number }> {
218
+ const { linkMode } = request;
219
+ const portWaitMs = io.portWaitMs ?? (linkMode ? linkPortWaitMs() : LINK_PORT_WAIT_MS);
220
+ const deadline = Date.now() + portWaitMs;
221
+ const busy = (cause: unknown) => new Error(
222
+ `link mode needs port ${request.configuredPort}; free it or change port`,
223
+ { cause },
224
+ );
225
+ let port: number;
226
+ try {
227
+ if (linkMode) {
228
+ const { reclaimListenPort } = await import("../server/port-reclaim");
229
+ await reclaimListenPort(request.configuredPort, "127.0.0.1", {
230
+ timeoutMs: portWaitMs,
231
+ intervalMs: 100,
232
+ scanIntervalMs: 500,
233
+ killOcxHolders: false,
234
+ dropTcpRows: true,
235
+ });
236
+ }
237
+ port = await findAvailablePort(request.preferred, "127.0.0.1", {
238
+ preferRetryMs: request.explicitPort ? PINNED_PREFER_RETRY_MS : 750,
239
+ preferRetryIntervalMs: 50,
240
+ allowEphemeralFallback: linkMode ? false : !request.explicitPort,
241
+ });
242
+ } catch (error) {
243
+ if (linkMode && error instanceof PortUnavailableError) throw busy(error);
244
+ throw error;
245
+ }
246
+ const startListener = io.startListener ?? startMachineListener;
247
+ for (let attempt = 1; ; attempt += 1) {
248
+ try {
249
+ const server = startListener(port, {
250
+ state: request.state,
251
+ ...(request.linkStatus ? { linkStatus: request.linkStatus } : {}),
252
+ ...(request.linkKeySource ? { linkKeySource: request.linkKeySource } : {}),
253
+ ...(request.linkTunnel ? { linkTunnel: request.linkTunnel } : {}),
254
+ });
255
+ return { server, port: server.port ?? port };
256
+ } catch (error) {
257
+ // Check-then-bind: the port can be taken between the probe and Bun.serve.
258
+ if (!linkMode || !isAddrInUse(error)) throw error;
259
+ const waitMs = Math.max(1_000, deadline - Date.now());
260
+ if (attempt >= LINK_BIND_ATTEMPTS
261
+ || !(await waitForPortAvailable(port, "127.0.0.1", { timeoutMs: waitMs, intervalMs: 100 }))) {
262
+ throw busy(error);
263
+ }
264
+ }
96
265
  }
97
- process.exit(0);
98
266
  }
99
267
 
100
268
  export async function startClientRuntime(
101
269
  options: { port?: number; block?: boolean } = {},
270
+ io: ClientRuntimeIo = {},
102
271
  ): Promise<void> {
103
272
  const state = readClientConnectionState();
104
273
  if (state.kind !== "connected") throw new Error(`client runtime refused: client state is ${state.kind}`);
@@ -108,33 +277,39 @@ export async function startClientRuntime(
108
277
  throw new Error("link mode requires a valid local config port");
109
278
  }
110
279
  const preferred = linkMode ? config.port : options.port ?? config.port ?? 10100;
111
- let port: number;
112
- try {
113
- port = await findAvailablePort(preferred, "127.0.0.1", {
114
- preferRetryMs: options.port === undefined ? 750 : 5_000,
115
- preferRetryIntervalMs: 50,
116
- allowEphemeralFallback: linkMode ? false : options.port === undefined,
117
- });
118
- } catch (error) {
119
- if (linkMode && error instanceof PortUnavailableError) {
120
- throw new Error(`link mode needs port ${config.port}; free it or change port`, { cause: error });
121
- }
122
- throw error;
280
+ // Share one cached key source between the listener and its tunnel supervisor.
281
+ const linkKey = linkMode ? createLinkKeySource(state.value.tokenFingerprint) : undefined;
282
+ // Joins made before the marker existed get one now, while their sidecar is still here.
283
+ if (linkMode && state.value.link) {
284
+ try { recordChildInitiatedLink(state.value.link.linkId); } catch { /* the sidecar check below still applies */ }
123
285
  }
124
- const server = startMachineListener(port, { state: state.value });
125
- const boundPort = server.port ?? port;
126
- activeServer = server;
127
- activePort = boundPort;
128
286
  const supervisor = linkMode && existsSync(clientLinkStatePath())
129
287
  ? createClientLinkSupervisor({
130
288
  onLinkEnded: () => scheduleStandaloneRecycle(state.value.tokenFingerprint),
289
+ linkKey,
131
290
  })
132
291
  : null;
292
+ // A Child with no sidecar and no record of joining itself was connected by its Home over
293
+ // `ssh -R`; that link keeps 2.67.0's unproven forward. A Child-initiated link that lost its
294
+ // sidecar gets no gate at all, so the relay refuses it.
295
+ const homeInitiated = linkMode && !supervisor && !!state.value.link
296
+ && !isChildInitiatedLink(state.value.link.linkId);
297
+ const { server, port: boundPort } = await bindClientListener({
298
+ state: state.value,
299
+ linkMode,
300
+ preferred,
301
+ explicitPort: options.port !== undefined,
302
+ configuredPort: config.port,
303
+ ...(linkMode ? { linkStatus: () => supervisor?.status() ?? { kind: "stopped" as const }, linkKeySource: linkKey } : {}),
304
+ ...(supervisor ? { linkTunnel: supervisor } : homeInitiated ? { linkTunnel: HOME_INITIATED_LINK_TUNNEL } : {}),
305
+ }, io);
306
+ activeServer = server;
307
+ activePort = boundPort;
133
308
  activeSupervisor = supervisor;
134
309
  supervisor?.start();
135
310
  installCrashGuards();
136
311
  writePid(process.pid);
137
- writeRuntimePort({ pid: process.pid, port: boundPort, hostname: "127.0.0.1" });
312
+ writeRuntimePort(clientRuntimeRecord(process.pid, boundPort));
138
313
 
139
314
  let shuttingDown = false;
140
315
  const shutdown = () => {
@@ -45,18 +45,7 @@ export const GAJAE_API_KEY_ENV = "OPENCODEX_GAJAE_API_KEY";
45
45
  /** Pi's wire-dialect selector for an OpenAI-compatible endpoint. */
46
46
  export const PI_API_DIALECT = "openai-completions";
47
47
 
48
- /**
49
- * opencode's config schema rejects a `limit` block that carries `context` without
50
- * `output`, but CatalogModel has no authoritative per-model output field. Dropping
51
- * `limit` entirely would also throw away the authoritative context window we DO have,
52
- * so the block is emitted with this budget standing in for the missing half.
53
- *
54
- * The value matches REASONING_MAX_TOKENS_CEILING in src/adapters/anthropic.ts — the
55
- * project's existing "safe ceiling across current models" figure. It is a ceiling for
56
- * schema validity, NOT a claim about any specific model's true maximum, and it is
57
- * clamped to the context window so a small-context model can never be emitted with
58
- * output > context. Pi's `maxTokens` uses the same stand-in and the same clamp.
59
- */
48
+ /** Fallback only when the model has no known output limit; always clamped to context. */
60
49
  export const SCHEMA_REQUIRED_OUTPUT_BUDGET = 32_000;
61
50
 
62
51
  /** Deterministic loopback default for exported provider-block helpers in tests. */
@@ -37,6 +37,7 @@ export interface OpencodeCatalogModel {
37
37
  provider?: string;
38
38
  id?: string;
39
39
  contextWindow?: number;
40
+ maxTokens?: number;
40
41
  displayName?: string;
41
42
  /**
42
43
  * Declared input modalities, carried verbatim from `/api/models`. Serialized as opencode's
@@ -71,6 +72,7 @@ export interface ExportModel {
71
72
  native?: boolean;
72
73
  displayName?: string;
73
74
  contextWindow?: number;
75
+ maxTokens?: number;
74
76
  inputModalities?: string[];
75
77
  /** Optional effort ladder exported only to clients that support it. */
76
78
  reasoningEfforts?: string[];
@@ -1,4 +1,5 @@
1
1
  // Shared client export model metadata.
2
+ import { getModelMetadata, resolveMetadataProvider } from "../../generated/model-metadata";
2
3
  import { SCHEMA_REQUIRED_OUTPUT_BUDGET } from "./constants";
3
4
  import { expandFastExportModels } from "./fast-models";
4
5
  import type { OpencodeCatalogModel, ExportModel, ExportClientId, ManagedContribution } from "./contracts";
@@ -18,9 +19,14 @@ export function authoritativeContextWindow(contextWindow: number | undefined): n
18
19
  return undefined;
19
20
  }
20
21
 
21
- /** Schema-required output budget for a known context window. */
22
- export function outputBudgetFor(context: number): number {
23
- return Math.min(SCHEMA_REQUIRED_OUTPUT_BUDGET, context);
22
+ /** Known model output limit, with a schema-required fallback, clamped to context. */
23
+ export function outputBudgetFor(context: number, model: OpencodeCatalogModel): number {
24
+ const provider = model.provider ?? "";
25
+ const metadata = getModelMetadata(resolveMetadataProvider(provider) ?? provider, model.id ?? model.namespaced);
26
+ const limit = authoritativeContextWindow(model.maxTokens)
27
+ ?? authoritativeContextWindow(metadata?.maxTokens)
28
+ ?? SCHEMA_REQUIRED_OUTPUT_BUDGET;
29
+ return Math.min(limit, context);
24
30
  }
25
31
 
26
32
  /**
@@ -67,7 +67,7 @@ export function buildOmpClientConfig(ctx: ExportContext): OmpGeneratedConfig {
67
67
  const context = authoritativeContextWindow(model.contextWindow);
68
68
  if (context !== undefined) {
69
69
  entry.contextWindow = context;
70
- entry.maxTokens = outputBudgetFor(context);
70
+ entry.maxTokens = outputBudgetFor(context, model);
71
71
  }
72
72
  const efforts = ompEfforts(model);
73
73
  if (efforts.length > 0) {
@@ -42,7 +42,7 @@ export interface ZcodeStoreProviderRule {
42
42
  providerName: string;
43
43
  config: {
44
44
  group: string;
45
- access: { apiKey: string };
45
+ access: { type: "api-key"; apiKey: string };
46
46
  api: { type: string; baseUrl: string };
47
47
  personalModelIds: string[];
48
48
  modelOrder: string[];
@@ -113,7 +113,7 @@ export function buildZcodeStoreProviderRule(ctx: ExportContext): ZcodeStoreProvi
113
113
  providerName: ZCODE_STORE_PROVIDER_NAME,
114
114
  config: {
115
115
  group: ZCODE_STORE_PROVIDER_GROUP,
116
- access: { apiKey: LOOPBACK_API_KEY_PLACEHOLDER },
116
+ access: { type: "api-key", apiKey: LOOPBACK_API_KEY_PLACEHOLDER },
117
117
  api: { type: ZCODE_STORE_API_TYPE, baseUrl: `${ctx.baseUrl.replace(/\/v1\/?$/, "")}/v1` },
118
118
  personalModelIds: ids,
119
119
  /*
@@ -722,7 +722,7 @@ export function opencodeProviderBlocks(
722
722
  const entry: OpencodeModelEntry = { name: exportModelLabel(model) };
723
723
  const context = authoritativeContextWindow(model.contextWindow);
724
724
  if (context !== undefined) {
725
- entry.limit = { context, output: outputBudgetFor(context) };
725
+ entry.limit = { context, output: outputBudgetFor(context, model) };
726
726
  }
727
727
  // `attachment` / `modalities` are fields of opencode's V1 model schema — the shape its
728
728
  // published config.json defines and the one its loader reads (verified against opencode
@@ -934,6 +934,17 @@ export interface GajaeModelEntry {
934
934
  input: string[];
935
935
  contextWindow?: number;
936
936
  maxTokens?: number;
937
+ /** Advertised only when the catalog declares at least one wire-selectable effort. */
938
+ reasoning?: true;
939
+ /** GJC's per-model reasoning-effort capability declaration. */
940
+ thinking?: {
941
+ mode: "effort";
942
+ minLevel: string;
943
+ maxLevel: string;
944
+ levels: string[];
945
+ };
946
+ /** Tells GJC to pass the selected level as OpenAI-compatible reasoning_effort. */
947
+ compat?: { supportsReasoningEffort: true };
937
948
  }
938
949
 
939
950
  /** Gajae validates strictly: an unknown field fails the whole config. */
@@ -1004,7 +1015,7 @@ function buildPiClientConfig(ctx: ExportContext, options: PiExportOptions = {}):
1004
1015
  const context = authoritativeContextWindow(model.contextWindow);
1005
1016
  if (context !== undefined) {
1006
1017
  entry.contextWindow = context;
1007
- entry.maxTokens = outputBudgetFor(context);
1018
+ entry.maxTokens = outputBudgetFor(context, model);
1008
1019
  }
1009
1020
  models.push(entry);
1010
1021
  }
@@ -1140,7 +1151,29 @@ function buildGajaeClientConfig(ctx: ExportContext): GajaeGeneratedConfig {
1140
1151
  const context = authoritativeContextWindow(model.contextWindow);
1141
1152
  if (context !== undefined) {
1142
1153
  entry.contextWindow = context;
1143
- entry.maxTokens = outputBudgetFor(context);
1154
+ entry.maxTokens = outputBudgetFor(context, model);
1155
+ }
1156
+ // GJC accepts the OpenAI-compatible effort ladder in model metadata. `none` means
1157
+ // no parameter and `ultra` is an OCX orchestration level that folds to `max` on the
1158
+ // wire, so neither can be offered as a GJC model-level effort.
1159
+ const declaredEfforts = model.reasoningEfforts
1160
+ // Native Codex rows do not repeat their built-in ladder in the catalog. They still
1161
+ // accept the standard effort field, so omitting this fallback hides GJC's thinking
1162
+ // control for the models most likely to need it.
1163
+ ?? (model.native && model.provider === "openai"
1164
+ ? ["low", "medium", "high", "xhigh", "max"]
1165
+ : []);
1166
+ const efforts = canonicalizeReasoningEfforts(declaredEfforts)
1167
+ .filter(effort => effort !== "none" && effort !== "ultra");
1168
+ if (efforts.length > 0) {
1169
+ entry.reasoning = true;
1170
+ entry.thinking = {
1171
+ mode: "effort",
1172
+ minLevel: efforts[0]!,
1173
+ maxLevel: efforts.at(-1)!,
1174
+ levels: efforts,
1175
+ };
1176
+ entry.compat = { supportsReasoningEffort: true };
1144
1177
  }
1145
1178
  models.push(entry);
1146
1179
  }