@bitkyc08/opencodex 2.75.0 → 2.76.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 (217) hide show
  1. package/README.md +6 -1
  2. package/bin/ocx.mjs +12 -1
  3. package/gui/dist/assets/App-CddorHdA.js +51 -0
  4. package/gui/dist/assets/App-DMudtISW.css +1 -0
  5. package/gui/dist/assets/{Tray-CGAD2AGk.js → Tray-DjNwwrWS.js} +1 -1
  6. package/gui/dist/assets/index-Btze1I3T.css +1 -0
  7. package/gui/dist/assets/index-CvnY7E6i.js +86 -0
  8. package/gui/dist/assets/quota-summary-B_AWpHfP.js +1 -0
  9. package/gui/dist/index.html +2 -2
  10. package/gui/dist/provider-icons/opengateway.svg +12 -0
  11. package/package.json +1 -1
  12. package/src/adapters/anthropic/account-metadata.ts +17 -0
  13. package/src/adapters/anthropic/client-identity.ts +69 -0
  14. package/src/adapters/anthropic/passthrough.ts +23 -4
  15. package/src/adapters/anthropic.ts +18 -1
  16. package/src/adapters/command-code-project-context.ts +7 -11
  17. package/src/adapters/devin/cloud-direct/tool-schema.ts +12 -48
  18. package/src/adapters/kiro-retry.ts +23 -8
  19. package/src/adapters/openai-chat/messages.ts +1 -1
  20. package/src/adapters/registry.ts +8 -1
  21. package/src/adapters/zed.ts +377 -0
  22. package/src/bridge/sse.ts +4 -2
  23. package/src/chatgpt/app-server-shim/app-server-rewrite.ts +45 -0
  24. package/src/chatgpt/app-server-shim/bundle-trust.ts +77 -0
  25. package/src/chatgpt/app-server-shim/filter.ts +157 -0
  26. package/src/chatgpt/app-server-shim/gate-rewrite.ts +99 -0
  27. package/src/chatgpt/app-server-shim/launcher.ts +91 -0
  28. package/src/claude/desktop-3p-startup.ts +62 -0
  29. package/src/claude/desktop-3p.ts +5 -0
  30. package/src/claude/first-party-settings.ts +14 -0
  31. package/src/claude/intercept/cli-catalog.ts +150 -0
  32. package/src/claude/intercept/cli-picker.ts +82 -0
  33. package/src/claude/intercept/listener.ts +21 -6
  34. package/src/claude/intercept/picker-bootstrap.ts +23 -5
  35. package/src/claude/intercept/picker-models.ts +57 -13
  36. package/src/claude/intercept/runtime.ts +30 -2
  37. package/src/claude/intercept/settings.ts +3 -0
  38. package/src/cli/agent.ts +172 -0
  39. package/src/cli/alias.ts +2 -1
  40. package/src/cli/capabilities-command.ts +3 -2
  41. package/src/cli/capabilities.ts +87 -0
  42. package/src/cli/chatgpt-command.ts +140 -0
  43. package/src/cli/combo.ts +144 -19
  44. package/src/cli/config-command.ts +3 -1
  45. package/src/cli/dispatch.ts +10 -1
  46. package/src/cli/help.ts +1 -0
  47. package/src/cli/index.ts +5 -24
  48. package/src/cli/init.ts +16 -2
  49. package/src/cli/integrations.ts +13 -0
  50. package/src/cli/internal-command.ts +12 -5
  51. package/src/cli/observe.ts +6 -1
  52. package/src/cli/registry.ts +6 -0
  53. package/src/cli/route-policy.ts +3 -1
  54. package/src/cli/runtime-api.ts +5 -2
  55. package/src/cli/status-oauth.ts +5 -0
  56. package/src/cli/status.ts +8 -1
  57. package/src/cli/stop-approval.ts +55 -16
  58. package/src/cli/stop-report.ts +5 -0
  59. package/src/clients/lazycodex.ts +48 -0
  60. package/src/clients/omo-role-models.ts +111 -0
  61. package/src/codex/account-credit-use.ts +99 -0
  62. package/src/codex/account-lifecycle.ts +2 -0
  63. package/src/codex/agent-role-models.ts +171 -0
  64. package/src/codex/auth-api/account-list.ts +10 -3
  65. package/src/codex/auth-api/routes.ts +34 -0
  66. package/src/codex/auth-context.ts +52 -11
  67. package/src/codex/catalog/discovered-natives.ts +11 -1
  68. package/src/codex/catalog/gather-capture.ts +6 -1
  69. package/src/codex/catalog/model-hints.ts +9 -1
  70. package/src/codex/catalog/provider-models.ts +60 -0
  71. package/src/codex/catalog/routed-gather.ts +30 -5
  72. package/src/codex/convergence-types.ts +2 -1
  73. package/src/codex/main-account-external-usage.ts +62 -0
  74. package/src/codex/main-account-hard-lock.ts +24 -13
  75. package/src/codex/native-profile-processes.ts +53 -16
  76. package/src/codex/prompt-layers/toml-edit.ts +1 -1
  77. package/src/codex/quota-types.ts +3 -7
  78. package/src/codex/quota.ts +19 -4
  79. package/src/codex/role-auto-assign.ts +159 -0
  80. package/src/codex/role-sizing-limits.ts +2 -0
  81. package/src/codex/role-sizing.ts +216 -0
  82. package/src/codex/routing/selection.ts +36 -0
  83. package/src/codex/routing/thread-affinity.ts +2 -0
  84. package/src/codex/routing.ts +4 -4
  85. package/src/codex/subagent-model-fallback.ts +82 -26
  86. package/src/codex/subagent-selectable-models.ts +32 -0
  87. package/src/combos/index.ts +18 -0
  88. package/src/combos/jev-decision-contract.ts +20 -0
  89. package/src/combos/jev-dispatch.ts +18 -0
  90. package/src/combos/jev-model-backend.ts +127 -0
  91. package/src/combos/jev.ts +249 -33
  92. package/src/combos/types.ts +125 -2
  93. package/src/config/diagnostics.ts +18 -0
  94. package/src/config/macos-system-proxy.ts +72 -15
  95. package/src/config/owner-registry.ts +13 -4
  96. package/src/config/process-state.ts +17 -8
  97. package/src/config/proxy-env.ts +35 -5
  98. package/src/config/schema/config-schema.ts +30 -1
  99. package/src/config/schema/leaf-validators.ts +4 -0
  100. package/src/config/serving-runtimes.ts +95 -1
  101. package/src/generated/compatibility-version.json +336 -156
  102. package/src/lib/errors.ts +7 -7
  103. package/src/lib/jsonc.ts +13 -0
  104. package/src/lib/local-chat-completion.ts +144 -0
  105. package/src/lib/provider-outbound.ts +87 -1
  106. package/src/lib/provider-runtime-fetch.ts +23 -0
  107. package/src/lib/provider-tls-profile.ts +221 -0
  108. package/src/lib/request-execution-budget.ts +16 -6
  109. package/src/lib/spend-reservation-ledger.ts +54 -20
  110. package/src/lib/sse-decoder.ts +20 -17
  111. package/src/lib/synced-state-location.ts +96 -0
  112. package/src/lib/winsw.ts +14 -5
  113. package/src/oauth/anthropic-account-refusal.ts +53 -4
  114. package/src/oauth/anthropic-model-quota.ts +83 -0
  115. package/src/oauth/anthropic-rate-limit-policy.ts +37 -0
  116. package/src/oauth/anthropic-routing.ts +112 -58
  117. package/src/oauth/health.ts +39 -3
  118. package/src/oauth/index.ts +14 -0
  119. package/src/oauth/kiro-account-load.ts +10 -0
  120. package/src/oauth/zed.ts +134 -0
  121. package/src/providers/model-discovery.ts +19 -8
  122. package/src/providers/model-rename-fields.ts +1 -0
  123. package/src/providers/provider-id-rewrite.ts +9 -2
  124. package/src/providers/quota/account-cache.ts +29 -7
  125. package/src/providers/quota/anthropic-cooldown-recovery.ts +18 -0
  126. package/src/providers/quota/anthropic-family-headers.ts +25 -0
  127. package/src/providers/quota/vendor-probes-oauth.ts +5 -1
  128. package/src/providers/quota-types.ts +4 -0
  129. package/src/providers/registry/entries-core.ts +18 -0
  130. package/src/providers/registry/entries-extended.ts +55 -0
  131. package/src/providers/registry/types.ts +3 -0
  132. package/src/providers/xai-fast-model.ts +12 -4
  133. package/src/providers/zed.ts +598 -0
  134. package/src/responses/state/snapshot-select.ts +22 -9
  135. package/src/responses/state.ts +2 -3
  136. package/src/router.ts +9 -0
  137. package/src/server/anthropic-rate-limit-headers.ts +7 -0
  138. package/src/server/auth-cors.ts +7 -0
  139. package/src/server/chat-completions.ts +9 -4
  140. package/src/server/claude-messages.ts +59 -21
  141. package/src/server/index/claude-intercept-lifecycle.ts +71 -25
  142. package/src/server/index/optional-listeners.ts +4 -0
  143. package/src/server/index/spend-ledger-lifecycle.ts +4 -0
  144. package/src/server/management/agent-settings-routes.ts +74 -37
  145. package/src/server/management/claude-desktop-picker-routes.ts +7 -0
  146. package/src/server/management/claude-intercept-routes.ts +35 -0
  147. package/src/server/management/codex-agent-role-routes.ts +122 -0
  148. package/src/server/management/codex-role-auto-assign.ts +208 -0
  149. package/src/server/management/combo-routes.ts +62 -5
  150. package/src/server/management/config-routes.ts +35 -3
  151. package/src/server/management/context.ts +4 -1
  152. package/src/server/management/decision-discovery.ts +74 -0
  153. package/src/server/management/decision-model-validation.ts +80 -0
  154. package/src/server/management/decision-routes.ts +160 -0
  155. package/src/server/management/native-integration-routes.ts +16 -0
  156. package/src/server/management/provider-routes.ts +30 -34
  157. package/src/server/management/route-registry.ts +12 -0
  158. package/src/server/management/sibling-guard.ts +1 -0
  159. package/src/server/management/zed-provider-probe.ts +38 -0
  160. package/src/server/management-api.ts +12 -1
  161. package/src/server/messages-native-oauth.ts +7 -2
  162. package/src/server/messages-native.ts +7 -3
  163. package/src/server/relay.ts +3 -1
  164. package/src/server/request-log.ts +2 -0
  165. package/src/server/responses/adapter-continuation.ts +3 -3
  166. package/src/server/responses/adapter-dispatch.ts +18 -3
  167. package/src/server/responses/compact.ts +14 -1
  168. package/src/server/responses/core-auth.ts +2 -0
  169. package/src/server/responses/core-combo.ts +67 -11
  170. package/src/server/responses/core-normalize.ts +21 -1
  171. package/src/server/responses/core-options.ts +11 -1
  172. package/src/server/responses/core.ts +5 -3
  173. package/src/server/responses/fetch-helpers.ts +6 -3
  174. package/src/server/responses/jev-model-invoke.ts +190 -0
  175. package/src/server/responses/passthrough-delivery.ts +16 -0
  176. package/src/server/responses/policy-fallback.ts +55 -3
  177. package/src/server/responses/policy-request-scope.ts +80 -0
  178. package/src/server/responses/request-prepare.ts +70 -18
  179. package/src/server/responses/request-transport.ts +54 -23
  180. package/src/server/responses/run-turn-execution.ts +1 -0
  181. package/src/server/responses/sidecar-execution.ts +9 -3
  182. package/src/server/responses-hosted-image-display.ts +225 -0
  183. package/src/service/managing-cli.ts +34 -4
  184. package/src/service/systemd-env.ts +75 -0
  185. package/src/service/windows-guarded-manager.ts +60 -19
  186. package/src/service/windows-scheduler.ts +22 -3
  187. package/src/service/windows-taskxml.ts +32 -10
  188. package/src/service-manager-probe.ts +72 -19
  189. package/src/types/config.ts +31 -1
  190. package/src/types/provider.ts +2 -0
  191. package/src/types/request.ts +2 -0
  192. package/src/update/index.ts +39 -6
  193. package/src/update/job.ts +63 -63
  194. package/src/update/npm-cache-preflight.d.mts +33 -1
  195. package/src/update/npm-cache-preflight.mjs +131 -18
  196. package/src/update/ownership-transaction.ts +19 -1
  197. package/src/update/restart-ownership.ts +80 -6
  198. package/src/update/transactional-install.d.mts +2 -0
  199. package/src/update/transactional-install.mjs +6 -1
  200. package/src/usage/jev-stats.ts +28 -1
  201. package/src/usage/log.ts +1 -1
  202. package/src/vision/anthropic-describe.ts +5 -3
  203. package/src/vision/index.ts +1 -0
  204. package/src/vision/plan.ts +2 -1
  205. package/src/vision/routed-describe.ts +29 -121
  206. package/src/web-search/alpha-search.ts +3 -3
  207. package/src/web-search/anthropic-executor.ts +6 -4
  208. package/src/web-search/index.ts +1 -1
  209. package/src/web-search/loop.ts +3 -3
  210. package/src/web-search/passthrough-bridge.ts +2 -1
  211. package/src/web-search/run-turn-loop.ts +1 -1
  212. package/src/web-search/sidecar-providers.ts +2 -1
  213. package/gui/dist/assets/App-ia4U0ufY.css +0 -1
  214. package/gui/dist/assets/App-xm0zGNOe.js +0 -51
  215. package/gui/dist/assets/index-DBT3Q31Y.css +0 -1
  216. package/gui/dist/assets/index-D_RGAT6Q.js +0 -86
  217. package/gui/dist/assets/quota-summary-CUFq48t-.js +0 -1
@@ -7,8 +7,8 @@ import { dirname } from "node:path";
7
7
  import { nativeOpenAiContextWindow, type NativeContextLimitsInput } from "../../codex/catalog";
8
8
  import type { OcxClaudeDesktopProfile } from "../../types";
9
9
  import { aliasForRoute, claudeCodeNativeAlias } from "../alias";
10
- import { AUTO_CONTEXT_OFF, withOneMillionMarker } from "../context-windows";
11
- import { displayModelId, type Desktop3pRoutedModel } from "../desktop-3p";
10
+ import { AUTO_CONTEXT_OFF, stripOneMillionMarker, withOneMillionMarker } from "../context-windows";
11
+ import { activeDesktop3pAlias, displayModelId, resolveDesktop3pAlias, type Desktop3pRoutedModel } from "../desktop-3p";
12
12
  import { reconcileDesktopProfile, renderDesktopProfile, type DesktopProfileModel } from "../desktop-profile";
13
13
  import type { PickerModelEntry } from "./picker-bootstrap";
14
14
 
@@ -19,7 +19,8 @@ export interface PickerRouteInput {
19
19
  nativeContextCap?: NativeContextLimitsInput;
20
20
  }
21
21
 
22
- export function buildPickerModels(input: PickerRouteInput): PickerModelEntry[] {
22
+ /** Candidate routes in the gateway profile's order and with its labels. */
23
+ function renderPickerCandidates(input: PickerRouteInput): DesktopProfileModel[] {
23
24
  const candidates: DesktopProfileModel[] = [
24
25
  ...input.nativeSlugs.map(id => {
25
26
  const contextWindow = nativeOpenAiContextWindow(id, input.nativeContextCap);
@@ -35,27 +36,64 @@ export function buildPickerModels(input: PickerRouteInput): PickerModelEntry[] {
35
36
  ...(contextWindow === undefined ? {} : { contextWindow }),
36
37
  })),
37
38
  ];
38
- const rendered = input.profile
39
+ return input.profile
39
40
  ? renderDesktopProfile(reconcileDesktopProfile(input.profile, candidates), candidates)
40
41
  : candidates;
42
+ }
43
+
44
+ function splitRoute(route: string): { provider: string; id: string } {
45
+ const slash = route.indexOf("/");
46
+ return { provider: route.slice(0, slash), id: route.slice(slash + 1) };
47
+ }
48
+
49
+ /** Desktop runners do not inherit the proxy's compaction env: mark only real >=1M windows. */
50
+ function pickerSelector(alias: string, contextWindow: number | undefined): string {
51
+ return withOneMillionMarker(alias, contextWindow === undefined ? {} : { [alias]: contextWindow }, AUTO_CONTEXT_OFF)!;
52
+ }
53
+
54
+ export function buildPickerModels(input: PickerRouteInput): PickerModelEntry[] {
55
+ const rendered = renderPickerCandidates(input);
41
56
  const out: PickerModelEntry[] = [];
42
57
  const seen = new Set<string>();
43
58
  for (const model of rendered) {
44
- const slash = model.route.indexOf("/");
45
- const provider = model.route.slice(0, slash);
46
- const id = model.route.slice(slash + 1);
59
+ const { provider, id } = splitRoute(model.route);
47
60
  if (provider === "anthropic" && id.startsWith("claude-")) continue;
48
61
  const alias = provider === "native" ? claudeCodeNativeAlias(id) : aliasForRoute(provider, id);
49
62
  if (!alias || seen.has(alias)) continue;
50
63
  seen.add(alias);
51
- // Desktop runners do not inherit the proxy's compaction env: mark only real >=1M windows.
52
- const selector = withOneMillionMarker(alias, model.contextWindow === undefined ? {} : { [alias]: model.contextWindow }, AUTO_CONTEXT_OFF)!;
53
- out.push({ id: selector, name: model.label,
64
+ out.push({ id: pickerSelector(alias, model.contextWindow), name: model.label,
54
65
  ...(model.contextWindow === undefined ? {} : { contextWindow: model.contextWindow }) });
55
66
  }
56
67
  return out;
57
68
  }
58
69
 
70
+ /**
71
+ * Rows for the Claude Code CLI's `cc` catalog. The CLI only offers Claude-shaped ids, so each route
72
+ * uses its Desktop 3P registry alias, and only one the registry decodes back to the same route: an
73
+ * id the router cannot resolve is never advertised. Real Anthropic rows are already in the catalog.
74
+ */
75
+ export function buildCliPickerModels(input: PickerRouteInput): PickerModelEntry[] {
76
+ const out: PickerModelEntry[] = [];
77
+ const seen = new Set<string>();
78
+ for (const model of renderPickerCandidates(input)) {
79
+ const { provider, id } = splitRoute(model.route);
80
+ if (provider === "anthropic" && id.startsWith("claude-")) continue;
81
+ const alias = activeDesktop3pAlias(provider, id);
82
+ if (seen.has(alias) || resolveDesktop3pAlias(alias) !== model.route) continue;
83
+ seen.add(alias);
84
+ out.push({ id: pickerSelector(alias, model.contextWindow), name: model.label,
85
+ description: `opencodex · ${model.route}`, route: model.route,
86
+ ...(model.contextWindow === undefined ? {} : { contextWindow: model.contextWindow }) });
87
+ }
88
+ return out;
89
+ }
90
+
91
+ /** Rows whose alias still decodes to the route it was minted for: the registry can be rebuilt after a snapshot was taken. */
92
+ export function routableCliPickerModels(models: readonly PickerModelEntry[]): PickerModelEntry[] {
93
+ return models.filter(model => model.route !== undefined
94
+ && resolveDesktop3pAlias(stripOneMillionMarker(model.id)) === model.route);
95
+ }
96
+
59
97
  export interface PickerModelSnapshot {
60
98
  current(): { models: PickerModelEntry[]; builtAt: number } | null;
61
99
  refresh(): Promise<void>;
@@ -68,11 +106,17 @@ function parseSnapshot(value: unknown): { models: PickerModelEntry[]; builtAt: n
68
106
  if (typeof candidate.builtAt !== "number" || !Number.isFinite(candidate.builtAt) || !Array.isArray(candidate.models)) return null;
69
107
  if (!candidate.models.every(model => model && typeof model === "object"
70
108
  && typeof model.id === "string" && typeof model.name === "string"
71
- && (model.contextWindow === undefined || typeof model.contextWindow === "number"))) return null;
109
+ && (model.contextWindow === undefined || typeof model.contextWindow === "number")
110
+ && (model.description === undefined || typeof model.description === "string")
111
+ && (model.route === undefined || typeof model.route === "string"))) return null;
72
112
  return { models: candidate.models as PickerModelEntry[], builtAt: candidate.builtAt };
73
113
  }
74
114
 
75
- export function createPickerModelSnapshot(load: () => Promise<PickerRouteInput>, persistPath?: string): PickerModelSnapshot {
115
+ export function createPickerModelSnapshot(
116
+ load: () => Promise<PickerRouteInput>,
117
+ persistPath?: string,
118
+ build: (input: PickerRouteInput) => PickerModelEntry[] = buildPickerModels,
119
+ ): PickerModelSnapshot {
76
120
  let snapshot: { models: PickerModelEntry[]; builtAt: number } | null = null;
77
121
  if (persistPath) {
78
122
  try { snapshot = parseSnapshot(JSON.parse(readFileSync(persistPath, "utf8")) as unknown); } catch { /* No usable prior snapshot. */ }
@@ -82,7 +126,7 @@ export function createPickerModelSnapshot(load: () => Promise<PickerRouteInput>,
82
126
  if (pending) return pending;
83
127
  pending = (async () => {
84
128
  try {
85
- const models = buildPickerModels(await load());
129
+ const models = build(await load());
86
130
  const next = { models, builtAt: Date.now() };
87
131
  if (persistPath) {
88
132
  mkdirSync(dirname(persistPath), { recursive: true, mode: 0o700 });
@@ -4,6 +4,7 @@ import { getConfigDir } from "../../config/paths";
4
4
  import type { DesktopPickerController } from "../desktop-picker";
5
5
  import type { ClaudeFirstPartyDesired } from "../first-party-settings";
6
6
  import { classifyInterceptClient, interceptRouteFor } from "./client-class";
7
+ import { createCliCatalogProvider } from "./cli-picker";
7
8
  import { CLAUDE_INTERCEPT_HOSTS, isBrowserConnect, startConnectProxy, type ConnectProxyHandle } from "./connect-proxy";
8
9
  import { startClaudeInterceptListener } from "./listener";
9
10
  import { claudeInterceptCaCertPath, ensureLocalInterceptCaForStartup, issueLocalInterceptLeaf } from "./local-ca";
@@ -50,7 +51,11 @@ export function claudePickerProxyPort(config: Pick<OcxConfig, "claudeCode">, pub
50
51
  return interceptPort < 65535 ? interceptPort + 1 : interceptPort - 1;
51
52
  }
52
53
 
54
+ export type ClaudeInterceptOutcome = { ok: true; state: ClaudeInterceptState } | { ok: false; reason: "disabled" | "client_role" | "ephemeral_port" | "port_in_use" | "stopped" | "failed"; port?: number; message?: string };
55
+
53
56
  export interface ClaudeInterceptState {
57
+ pickerReason?: "port_in_use" | "failed" | null;
58
+ pickerFailurePort?: number;
54
59
  proxyPort: number;
55
60
  caCertPath: string;
56
61
  /** Desktop egress proxy for picker mode; null when the picker is not wired or could not bind. */
@@ -101,6 +106,14 @@ export async function createPickerPreferenceWriter(live: OcxConfig): Promise<(va
101
106
  };
102
107
  }
103
108
 
109
+ export class ClaudeInterceptProxyBindError extends Error {
110
+ readonly code: unknown;
111
+ constructor(error: unknown, readonly port: number) {
112
+ super(error instanceof Error ? error.message : "CONNECT proxy bind failed", { cause: error });
113
+ this.code = error && typeof error === "object" && "code" in error ? error.code : undefined;
114
+ }
115
+ }
116
+
104
117
  export interface StartClaudeInterceptOptions<T> {
105
118
  config: OcxConfig;
106
119
  /** Bound public port; the derived proxy port is offset from it. */
@@ -160,6 +173,10 @@ export async function startClaudeIntercept<T>(options: StartClaudeInterceptOptio
160
173
  dispatch: options.dispatch,
161
174
  ...(options.desiredClients ? { route: (req: Request) =>
162
175
  interceptRouteFor(classifyInterceptClient(req.headers.get("user-agent")), options.desiredClients!()) } : {}),
176
+ // The CLI's /model catalog (cli-picker.ts): wired from the routes alone, never from Desktop picker state.
177
+ ...(options.loadPickerRoutes && options.desiredClients ? { cliCatalog: createCliCatalogProvider({
178
+ configDir, loadRoutes: options.loadPickerRoutes, desiredClients: options.desiredClients,
179
+ }) } : {}),
163
180
  upstreamBase: options.config.claudeCode?.anthropicBaseUrl,
164
181
  ...(options.maxRequestBodySize !== undefined ? { maxRequestBodySize: options.maxRequestBodySize } : {}),
165
182
  });
@@ -173,13 +190,15 @@ export async function startClaudeIntercept<T>(options: StartClaudeInterceptOptio
173
190
  });
174
191
  } catch (error) {
175
192
  await listener.stop(true);
176
- throw error;
193
+ throw new ClaudeInterceptProxyBindError(error, claudeInterceptProxyPort(options.config, options.publicPort));
177
194
  }
178
195
  // Widened on purpose: assignments happen in nested awaits the catch below must still see.
179
196
  let picker = null as PickerRuntime | null;
180
197
  let pickerProxy = null as ConnectProxyHandle | null;
181
198
  let controller = null as DesktopPickerController | null;
182
199
  let pickerProxyLive = false;
200
+ let pickerReason: ClaudeInterceptState["pickerReason"] = null;
201
+ let pickerFailurePort: number | undefined;
183
202
  try {
184
203
  if (options.loadPickerRoutes) {
185
204
  // A picker authority is process-scoped, and older releases persisted an exportable ca.key
@@ -208,9 +227,11 @@ export async function startClaudeIntercept<T>(options: StartClaudeInterceptOptio
208
227
  }
209
228
  } catch (error) {
210
229
  pickerBlocked = true;
230
+ pickerReason = "failed";
211
231
  console.warn(`⚠ Claude Desktop picker CA cleanup deferred: ${error instanceof Error ? error.message : String(error)}`);
212
232
  }
213
233
  if (pickerBlocked) {
234
+ pickerReason = "failed";
214
235
  console.warn("⚠ Claude Desktop picker disabled: the previous certificate could not be untrusted");
215
236
  if (pickerProfile.kind === "applied") {
216
237
  // Keep Desktop's actual pinned egress alive without ever constructing a TLS terminator.
@@ -223,6 +244,8 @@ export async function startClaudeIntercept<T>(options: StartClaudeInterceptOptio
223
244
  });
224
245
  pickerProxyLive = true;
225
246
  } catch (error) {
247
+ pickerReason = error && typeof error === "object" && "code" in error && error.code === "EADDRINUSE" ? "port_in_use" : "failed";
248
+ if (pickerReason === "port_in_use") pickerFailurePort = port;
226
249
  console.warn(`⚠ Claude Desktop blind egress relay could not start: ${error instanceof Error ? error.message : String(error)}`);
227
250
  }
228
251
  }
@@ -241,8 +264,9 @@ export async function startClaudeIntercept<T>(options: StartClaudeInterceptOptio
241
264
  if (picker) {
242
265
  const runtime = picker;
243
266
  const interceptPort = listener.port!;
267
+ const pickerPort = claudePickerProxyPort(options.config, options.publicPort);
244
268
  try {
245
- pickerProxy = await startProxy(claudePickerProxyPort(options.config, options.publicPort), {
269
+ pickerProxy = await startProxy(pickerPort, {
246
270
  interceptPort,
247
271
  // No authToken: Desktop's egressProxyUrl cannot present proxy credentials, so this
248
272
  // listener stays an unauthenticated loopback relay until the profile format can carry
@@ -267,6 +291,8 @@ export async function startClaudeIntercept<T>(options: StartClaudeInterceptOptio
267
291
  // Picker mode is optional: a busy port leaves the intercept pair running without it.
268
292
  // The selected profile row survives, so the next startup retries the restore once the
269
293
  // port is free again.
294
+ pickerReason = error && typeof error === "object" && "code" in error && error.code === "EADDRINUSE" ? "port_in_use" : "failed";
295
+ if (pickerReason === "port_in_use") pickerFailurePort = pickerPort;
270
296
  console.warn(`⚠ Claude Desktop picker proxy could not start: ${error instanceof Error ? error.message : String(error)}`);
271
297
  await runtime.stop();
272
298
  picker = null;
@@ -313,6 +339,8 @@ export async function startClaudeIntercept<T>(options: StartClaudeInterceptOptio
313
339
  throw error;
314
340
  }
315
341
  const state: ClaudeInterceptState = {
342
+ pickerReason,
343
+ pickerFailurePort,
316
344
  proxyPort: proxy.port,
317
345
  caCertPath: claudeInterceptCaCertPath(configDir),
318
346
  pickerProxyPort: pickerProxy?.port ?? null,
@@ -2,6 +2,7 @@ import { mkdirSync, readFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { atomicWriteFile } from "../../config/atomic-write";
4
4
  import { claudeConfigDir } from "../auth-detect";
5
+ import { invalidateClaudeCodeServedCatalog } from "./cli-catalog";
5
6
 
6
7
  /**
7
8
  * Claude Code `settings.json` env block for intercept mode.
@@ -115,6 +116,8 @@ function writeSettings(path: string, doc: SettingsDoc): void {
115
116
  // The managed env embeds the proxy token, so the file must stay owner-only:
116
117
  // atomicWriteFile applies the real NTFS ACL on Windows where chmod is a no-op.
117
118
  atomicWriteFile(path, `${JSON.stringify(doc, null, 2)}\n`);
119
+ // Whether Claude Code reaches the proxy just changed, so its cached /model list is stale.
120
+ invalidateClaudeCodeServedCatalog(dirname(path));
118
121
  }
119
122
 
120
123
  export type ClaudeInterceptSettingsWrite =
package/src/cli/agent.ts CHANGED
@@ -25,9 +25,11 @@ const USAGE = `Usage:
25
25
  ocx agent [status] [--json]
26
26
  ocx agent injection <status|set> [--model <id|->] [--effort <level|->]
27
27
  [--prompt <text|->] [--guidance <on|off>] [--json]
28
+ ocx agent injection suggest <work description> [--model <id>] [--apply] [--json]
28
29
  ocx agent effort <status|set> [--main <level|->] [--subagent <level|->] [--json]
29
30
  ocx agent subagents <status|set|clear> [model,model...] [--json]
30
31
  ocx agent fallback <status|set|clear> [model,model...] [--poll-ms <5000-600000>] [--json]
32
+ ocx agent roles [status|set <role> <model>|suggest [--model <id>] [--apply]] [--json]
31
33
  ocx agent sidecar <status|web|vision> [--list] [--model <id|->]
32
34
  [--backend web:<openai|anthropic|xai|gemini|exa|-> vision:<openai|anthropic|routed|->]
33
35
  [--reasoning <level>] [--max-descriptions <n>] [--enabled <on|off>] [--json]
@@ -53,6 +55,58 @@ async function status(argv: string[], deps: RuntimeApiDeps): Promise<void> {
53
55
  printData(result, wantsJson, summaryLines(result));
54
56
  }
55
57
 
58
+ interface DelegationProposal {
59
+ model: string | null;
60
+ effort: string | null;
61
+ status: "proposed" | "unassigned" | "unsized";
62
+ tier?: string;
63
+ effortIntent?: string;
64
+ rationale?: string;
65
+ moveUpIf?: string;
66
+ moveDownIf?: string;
67
+ proposedModel?: string | null;
68
+ proposedEffort?: string | null;
69
+ reason?: string | null;
70
+ }
71
+
72
+ async function suggestInjection(args: string[], wantsJson: boolean, deps: RuntimeApiDeps): Promise<void> {
73
+ const model = takeOption(args, "--model");
74
+ const apply = takeFlag(args, "--apply");
75
+ if (args.some(arg => arg.startsWith("--"))) rejectArgs(args, USAGE);
76
+ const work = args.join(" ").trim();
77
+ if (!work) throw new CliUsageError("describe the delegated work to size", USAGE);
78
+ const result = await runtimeRequest<{ sizingModel?: string; proposal?: DelegationProposal }>(
79
+ "/api/injection-model/suggest",
80
+ { method: "POST", body: JSON.stringify({ work, ...(model ? { model } : {}) }) },
81
+ deps,
82
+ );
83
+ const p = result.proposal;
84
+ const alreadySet = p?.status === "proposed" && !!p.proposedModel
85
+ && p.proposedModel === p.model && (p.proposedEffort ?? null) === (p.effort ?? null);
86
+ let applied: { model: string; effort: string | null } | null = null;
87
+ if (apply && !alreadySet && p?.status === "proposed" && p.proposedModel) {
88
+ applied = { model: p.proposedModel, effort: p.proposedEffort ?? null };
89
+ await runtimeRequest("/api/injection-model", { method: "PUT", body: JSON.stringify(applied) }, deps);
90
+ }
91
+ printData(apply ? { ...result, applied, alreadySet } : result, wantsJson, [
92
+ `Sized with ${result.sizingModel ?? "unknown"}.`,
93
+ !p || p.status === "unsized"
94
+ ? `Not sized (${p?.reason ?? "unknown"}).`
95
+ : p.status === "unassigned"
96
+ ? `${p.tier}/${p.effortIntent}, no model (${p.reason ?? "unknown"})`
97
+ : `${p.model ?? "(none)"}${p.effort ? ` (${p.effort})` : ""} -> ${p.proposedModel}${p.proposedEffort ? ` (${p.proposedEffort})` : ""} [${p.tier}/${p.effortIntent}] ${p.rationale ?? ""}`,
98
+ ...(p?.moveUpIf ? [`Move up if: ${p.moveUpIf}`] : []),
99
+ ...(p?.moveDownIf ? [`Move down if: ${p.moveDownIf}`] : []),
100
+ apply
101
+ ? (applied
102
+ ? "Applied to the delegation model."
103
+ : alreadySet
104
+ ? `Already set to ${p!.proposedModel}${p!.proposedEffort ? ` (${p!.proposedEffort})` : ""}; nothing applied.`
105
+ : "Nothing to apply.")
106
+ : "Nothing was written; rerun with --apply to set the delegation model.",
107
+ ]);
108
+ }
109
+
56
110
  async function injection(argv: string[], deps: RuntimeApiDeps): Promise<void> {
57
111
  const args = [...argv];
58
112
  const action = (args.shift() ?? "status").toLowerCase();
@@ -63,6 +117,10 @@ async function injection(argv: string[], deps: RuntimeApiDeps): Promise<void> {
63
117
  printData(result, wantsJson, summaryLines(result));
64
118
  return;
65
119
  }
120
+ if (action === "suggest") {
121
+ await suggestInjection(args, wantsJson, deps);
122
+ return;
123
+ }
66
124
  if (action !== "set") throw new CliUsageError(`unknown injection action ${action}`, USAGE);
67
125
  const model = clearable(takeOption(args, "--model"));
68
126
  const effort = clearable(takeOption(args, "--effort"));
@@ -236,6 +294,119 @@ async function sidecar(argv: string[], deps: RuntimeApiDeps): Promise<void> {
236
294
  printData(result, wantsJson, lines);
237
295
  }
238
296
 
297
+ interface CodexAgentRolesStatus {
298
+ lazycodex?: { detected?: boolean };
299
+ omoJsonc?: { state?: string } | null;
300
+ roles?: Array<{ role: string; model: string | null; omoJsoncModel: string | null }>;
301
+ }
302
+
303
+ interface CodexRoleProposal {
304
+ role: string;
305
+ model: string | null;
306
+ effort?: string | null;
307
+ status: "proposed" | "unassigned" | "unsized";
308
+ tier?: string;
309
+ effortIntent?: string;
310
+ rationale?: string;
311
+ proposedModel?: string | null;
312
+ proposedEffort?: string | null;
313
+ reason?: string | null;
314
+ }
315
+
316
+ function proposalAlreadySet(proposal: CodexRoleProposal): boolean {
317
+ return proposal.proposedModel === proposal.model
318
+ && (proposal.proposedEffort == null || proposal.proposedEffort === proposal.effort);
319
+ }
320
+
321
+ const OMO_JSONC_NOT_WRITTEN = new Set(["skipped_comments", "invalid", "write_failed"]);
322
+
323
+ async function suggestRoles(args: string[], wantsJson: boolean, deps: RuntimeApiDeps): Promise<void> {
324
+ const model = takeOption(args, "--model");
325
+ const apply = takeFlag(args, "--apply");
326
+ rejectArgs(args, USAGE);
327
+ const result = await runtimeRequest<{ sizingModel?: string; proposals?: CodexRoleProposal[] }>(
328
+ "/api/codex-agent-roles/auto-assign",
329
+ { method: "POST", body: JSON.stringify(model ? { model } : {}) },
330
+ deps,
331
+ );
332
+ const proposals = result.proposals ?? [];
333
+ const applied: Array<{ role: string; model: string; effort?: string }> = [];
334
+ const skipped: string[] = [];
335
+ const omoJsoncNotWritten: Array<{ role: string; status: string }> = [];
336
+ if (apply) {
337
+ for (const proposal of proposals) {
338
+ if (proposal.status !== "proposed" || !proposal.proposedModel) continue;
339
+ if (proposalAlreadySet(proposal)) {
340
+ skipped.push(proposal.role);
341
+ continue;
342
+ }
343
+ const body = { model: proposal.proposedModel, ...(proposal.proposedEffort ? { effort: proposal.proposedEffort } : {}) };
344
+ const written = await runtimeRequest<{ omoJsonc?: { status?: string } } | null>(
345
+ `/api/codex-agent-roles/${encodeURIComponent(proposal.role)}`,
346
+ { method: "PUT", body: JSON.stringify(body) },
347
+ deps,
348
+ );
349
+ applied.push({ role: proposal.role, ...body });
350
+ const omoStatus = written?.omoJsonc?.status;
351
+ if (omoStatus && OMO_JSONC_NOT_WRITTEN.has(omoStatus)) omoJsoncNotWritten.push({ role: proposal.role, status: omoStatus });
352
+ }
353
+ }
354
+ printData(apply ? { ...result, applied, skipped, omoJsoncNotWritten } : result, wantsJson, [
355
+ `Sized with ${result.sizingModel ?? "unknown"}.`,
356
+ ...proposals.map(p => p.status === "unsized"
357
+ ? `${p.role}: not sized (${p.reason ?? "unknown"})`
358
+ : p.status === "unassigned"
359
+ ? `${p.role}: ${p.tier}/${p.effortIntent}, no model (${p.reason ?? "unknown"})`
360
+ : `${p.role}: ${p.model ?? "(no pin)"} -> ${p.proposedModel}${p.proposedEffort ? ` (${p.proposedEffort})` : ""} [${p.tier}/${p.effortIntent}] ${p.rationale ?? ""}`),
361
+ apply
362
+ ? `Applied ${applied.length} of ${proposals.length} roles.${skipped.length > 0 ? ` Skipped ${skipped.length} already set: ${skipped.join(", ")}.` : ""}`
363
+ : "Nothing was written; rerun with --apply to write every proposal that differs from the role's current pin.",
364
+ ...omoJsoncNotWritten.map(entry => `${entry.role}: omo.jsonc not written (${entry.status}); the role TOML was updated.`),
365
+ ]);
366
+ }
367
+
368
+ async function roles(argv: string[], deps: RuntimeApiDeps): Promise<void> {
369
+ const args = [...argv];
370
+ const wantsJson = takeFlag(args, "--json");
371
+ const action = (args.shift() ?? "status").toLowerCase();
372
+ if (action === "status") {
373
+ rejectArgs(args, USAGE);
374
+ const result = await runtimeRequest<CodexAgentRolesStatus>("/api/codex-agent-roles", {}, deps);
375
+ if (result.lazycodex?.detected !== true) {
376
+ printData(result, wantsJson, ["omo (Codex / LazyCodex) is not installed in this CODEX_HOME; role models are managed only with it."]);
377
+ return;
378
+ }
379
+ const rows = result.roles ?? [];
380
+ printData(result, wantsJson, [
381
+ "omo (Codex / LazyCodex): detected",
382
+ ...(rows.length === 0 ? ["No Codex agent roles found."] : rows.map(row => `${row.role}: ${row.model ?? "(no model pin)"}`)),
383
+ `omo.jsonc: ${result.omoJsonc?.state ?? "unknown"}`,
384
+ ]);
385
+ return;
386
+ }
387
+ if (action === "suggest") {
388
+ await suggestRoles(args, wantsJson, deps);
389
+ return;
390
+ }
391
+ if (action !== "set") throw new CliUsageError(`unknown roles action ${action}`, USAGE);
392
+ const role = args.shift();
393
+ const model = args.shift();
394
+ if (!role || !model) throw new CliUsageError("a role and a model are required", USAGE);
395
+ rejectArgs(args, USAGE);
396
+ const result = await runtimeRequest<{ toml?: { status?: string }; omoJsonc?: { status?: string } }>(
397
+ `/api/codex-agent-roles/${encodeURIComponent(role)}`,
398
+ { method: "PUT", body: JSON.stringify({ model }) },
399
+ deps,
400
+ );
401
+ const omo = result.omoJsonc?.status;
402
+ printData(result, wantsJson, [
403
+ `${role}: ${model} (role TOML ${result.toml?.status ?? "unknown"})`,
404
+ omo === "skipped_comments"
405
+ ? "omo.jsonc: not written, because it contains comments that a rewrite would lose."
406
+ : `omo.jsonc: ${omo ?? "unknown"}`,
407
+ ]);
408
+ }
409
+
239
410
  export async function handleAgentCommand(argv: string[], deps: RuntimeApiDeps = {}): Promise<number> {
240
411
  return runCliAction(async () => {
241
412
  const [sub = "status", ...rest] = argv;
@@ -244,6 +415,7 @@ export async function handleAgentCommand(argv: string[], deps: RuntimeApiDeps =
244
415
  else if (sub === "effort") await effort(rest, deps);
245
416
  else if (sub === "subagents" || sub === "roster") await subagents(rest, deps);
246
417
  else if (sub === "fallback") await fallback(rest, deps);
418
+ else if (sub === "roles") await roles(rest, deps);
247
419
  else if (sub === "sidecar") await sidecar(rest, deps);
248
420
  // Lives here rather than as a top-level verb because it is an agent-behavior feature flag:
249
421
  // it controls whether default mode may ask the operator a question mid-task.
package/src/cli/alias.ts CHANGED
@@ -12,10 +12,11 @@ function selector(value: string): { provider: string; model?: string } {
12
12
  return slash < 0 ? { provider: value } : { provider: value.slice(0, slash), model: value.slice(slash + 1) };
13
13
  }
14
14
 
15
+ /** Manage model aliases, consuming JSON output selection before the default list action. */
15
16
  export async function handleAliasCommand(argv: string[], deps: RuntimeApiDeps = {}): Promise<number> {
16
17
  const args = [...argv];
17
- const action = (args.shift() ?? "list").toLowerCase();
18
18
  const wantsJson = takeFlag(args, "--json");
19
+ const action = (args.shift() ?? "list").toLowerCase();
19
20
  if (action === "list") {
20
21
  rejectArgs(args, USAGE);
21
22
  const result = await runtimeRequest<Record<string, unknown>>("/api/aliases", {}, deps);
@@ -45,14 +45,15 @@ function renderHuman(caps: readonly Capability[], includeHead: boolean): void {
45
45
  }
46
46
  }
47
47
 
48
+ /** Render declared capabilities; reject invalid arguments with 64 and report unmatched routes with 4. */
48
49
  export async function runCapabilities(argv: string[]): Promise<number> {
49
50
  const args = [...argv];
50
51
  const json = takeFlag(args, "--json");
51
52
  const mutatingOnly = takeFlag(args, "--mutating-only");
52
53
  const route = takeValueFlag(args, "--route");
53
54
 
54
- if (route !== undefined && route.length === 0) {
55
- console.error("Usage: ocx capabilities --route <path>");
55
+ if (args.length > 0 || (route !== undefined && route.trim().length === 0)) {
56
+ console.error("Usage: ocx capabilities [--json] [--mutating-only] [--route <path>]");
56
57
  return 64;
57
58
  }
58
59
 
@@ -95,6 +95,13 @@ export const HEAD_CAPABILITIES: readonly HeadCapability[] = [
95
95
  * A capability must not name a route the command does not actually fetch.
96
96
  */
97
97
  export const CAPABILITIES: readonly Capability[] = [
98
+ {
99
+ command: ["chatgpt"],
100
+ summary: "Experimental ChatGPT app-server shim: launch, restore or status (macOS only).",
101
+ routes: [], flags: [], mutates: true, json: "none",
102
+ bannerLines: ["ocx chatgpt <sub> Experimental app-server shim: launch|restore|status (macOS)"],
103
+ details: ["Default off; launch requires chatgptDesktop.appServerShim: true. Restore removes the generated launcher."],
104
+ },
98
105
  {
99
106
  command: ["link", "port"],
100
107
  summary: "Allocate a free loopback port for a remote home link.",
@@ -908,6 +915,14 @@ export const CAPABILITIES: readonly Capability[] = [
908
915
  json: "payload",
909
916
  details: ["`status` reads the route; `set` writes only submitted fields. Enabling first-party requires a running Claude intercept."],
910
917
  },
918
+ {
919
+ command: ["claude", "intercept", "start"],
920
+ summary: "Start the local Claude interception pair on demand.",
921
+ routes: [{ method: "POST", path: "/api/claude-intercept/start" }],
922
+ flags: [{ name: "--json", value: "boolean", summary: "Emit the management response as JSON." }],
923
+ mutates: true,
924
+ json: "payload",
925
+ },
911
926
  {
912
927
  command: ["claude", "desktop", "status"],
913
928
  summary: "Applied-vs-desired Claude Desktop state, including staleness, drift, and health.",
@@ -1060,6 +1075,51 @@ export const CAPABILITIES: readonly Capability[] = [
1060
1075
  json: "payload",
1061
1076
  details: ["A bare invocation reads and never writes."],
1062
1077
  },
1078
+ {
1079
+ command: ["agent", "roles"],
1080
+ summary: "omo (Codex / LazyCodex): show each Codex agent role's model pin, set one role's model in its TOML and in omo.jsonc, or suggest a model for every role.",
1081
+ routes: [
1082
+ { method: "GET", path: "/api/codex-agent-roles" },
1083
+ { method: "PUT", path: "/api/codex-agent-roles/{role}" },
1084
+ { method: "POST", path: "/api/codex-agent-roles/auto-assign" },
1085
+ ],
1086
+ flags: [
1087
+ { name: "--json", value: "boolean", summary: "Emit the role list, the write result or the proposals as JSON." },
1088
+ { name: "--model", value: "string", summary: "suggest: size the roles with this model instead of the Codex default model." },
1089
+ { name: "--apply", value: "boolean", summary: "suggest: write every proposal through the role model write." },
1090
+ ],
1091
+ mutates: true,
1092
+ json: "payload",
1093
+ details: [
1094
+ "A bare invocation reads and never writes.",
1095
+ "Requires Codex-based omo (LazyCodex): the omo@sisyphuslabs Codex plugin enabled in config.toml and installed; otherwise status lists no roles, and set and suggest are refused.",
1096
+ "set rewrites only the root model value of $CODEX_HOME/agents/<role>.toml; omo.jsonc is skipped when absent or when it contains comments.",
1097
+ "suggest sizes every role with one model call and prints proposals without writing; --apply writes each proposed model, and its effort when the role file already sets model_reasoning_effort.",
1098
+ ],
1099
+ },
1100
+ {
1101
+ command: ["agent", "injection"],
1102
+ summary: "Show or set the delegation model and effort, or suggest both for a described piece of delegated work.",
1103
+ routes: [
1104
+ { method: "GET", path: "/api/injection-model" },
1105
+ { method: "PUT", path: "/api/injection-model" },
1106
+ { method: "POST", path: "/api/injection-model/suggest" },
1107
+ ],
1108
+ flags: [
1109
+ { name: "--json", value: "boolean", summary: "Emit the delegation settings, the write result or the proposal as JSON." },
1110
+ { name: "--model", value: "string", summary: "set: the delegation model, - clears it. suggest: size the work with this model instead of the Codex default model." },
1111
+ { name: "--effort", value: "string", summary: "set: the delegation reasoning effort, - clears it." },
1112
+ { name: "--prompt", value: "string", summary: "set: a custom guidance prompt, - clears it." },
1113
+ { name: "--guidance", value: "string", summary: "set: on or off for OpenCodex delegation guidance." },
1114
+ { name: "--apply", value: "boolean", summary: "suggest: write the proposed model and effort through the delegation settings write." },
1115
+ ],
1116
+ mutates: true,
1117
+ json: "payload",
1118
+ details: [
1119
+ "A bare invocation reads and never writes.",
1120
+ "suggest sizes the described work with one model call, picks the cheapest sufficient model the delegation picker offers, and writes nothing unless --apply is given.",
1121
+ ],
1122
+ },
1063
1123
  {
1064
1124
  command: ["api", "protocols"],
1065
1125
  summary: "Read the protocol contract version, API surfaces, protocol settings and feature vocabulary.",
@@ -1071,6 +1131,33 @@ export const CAPABILITIES: readonly Capability[] = [
1071
1131
  mutates: false,
1072
1132
  json: "payload",
1073
1133
  },
1134
+ {
1135
+ command: ["combo", "test"],
1136
+ summary: "Run one JEV decision probe through a decision method: TypeSafe, a System One row, or an opencodex model.",
1137
+ routes: [{ method: "POST", path: "/api/combos/decision-test" }],
1138
+ flags: [
1139
+ { name: "--combo", value: "string", summary: "Combo id whose saved decision method is probed and whose recursion rules apply." },
1140
+ { name: "--decision-provider", value: "string", summary: "Probe a jev-decision provider row (or jev for TypeSafe) instead of the saved method." },
1141
+ { name: "--decision-model", value: "string", summary: "Probe an opencodex-routed model instead of the saved method." },
1142
+ { name: "--decision-timeout", value: "number", summary: "Decision deadline in milliseconds (1000-120000)." },
1143
+ { name: "--json", value: "boolean", summary: "Emit the probe result." },
1144
+ ],
1145
+ mutates: true,
1146
+ json: "payload",
1147
+ details: ["Sends one synthetic two-option decision; it may spend a decision call on the chosen backend."],
1148
+ },
1149
+ {
1150
+ command: ["combo", "discover"],
1151
+ summary: "List configured System One decision rows and catalog models that look like decision services.",
1152
+ routes: [{ method: "GET", path: "/api/combos/decision-discovery" }],
1153
+ flags: [
1154
+ { name: "--query", value: "string", summary: "Match catalog rows by this text instead of the built-in decision-model hint." },
1155
+ { name: "--json", value: "boolean", summary: "Emit the discovery payload." },
1156
+ ],
1157
+ mutates: false,
1158
+ json: "payload",
1159
+ details: ["Read-only: nothing is probed and no provider row is created."],
1160
+ },
1074
1161
  {
1075
1162
  command: ["api", "explain"],
1076
1163
  summary: "Preview the request path a model would take from one inbound API, computed from config.",