@bitkyc08/opencodex 2.87.0 → 2.89.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 (246) hide show
  1. package/README.md +11 -1
  2. package/bin/ocx.mjs +15 -4
  3. package/gui/dist/assets/App-BnRSujOq.js +58 -0
  4. package/gui/dist/assets/App-dnkJ85te.css +1 -0
  5. package/gui/dist/assets/{Tray-DCd-A1az.js → Tray-7z1COnmz.js} +1 -1
  6. package/gui/dist/assets/index-uchD-ngf.js +94 -0
  7. package/gui/dist/assets/{quota-summary-ChvmKGOk.js → quota-summary-BY_10IEz.js} +1 -1
  8. package/gui/dist/index.html +1 -1
  9. package/package.json +1 -1
  10. package/src/adapters/anthropic/cache-retention.ts +117 -0
  11. package/src/adapters/anthropic/passthrough.ts +7 -4
  12. package/src/adapters/anthropic-rejection-memo.ts +71 -0
  13. package/src/adapters/anthropic-replay.ts +135 -0
  14. package/src/adapters/anthropic-tool-catalog.ts +11 -0
  15. package/src/adapters/anthropic.ts +86 -48
  16. package/src/adapters/coding-agent/protocol.ts +6 -0
  17. package/src/adapters/google-http.ts +3 -2
  18. package/src/adapters/google-progress-history.ts +53 -0
  19. package/src/adapters/google-vertex-endpoint.ts +67 -0
  20. package/src/adapters/google.ts +52 -30
  21. package/src/adapters/openai-responses/compaction-search-history.ts +27 -0
  22. package/src/adapters/openai-responses/passthrough.ts +17 -12
  23. package/src/adapters/openai-responses/reasoning.ts +68 -1
  24. package/src/adapters/registry.ts +1 -1
  25. package/src/adapters/upstream-http-error.ts +3 -2
  26. package/src/adapters/zed.ts +7 -4
  27. package/src/bridge/internal.ts +17 -3
  28. package/src/bridge/response-json.ts +4 -4
  29. package/src/bridge/sse.ts +17 -10
  30. package/src/claude/inbound-content-options.ts +66 -0
  31. package/src/claude/inbound.ts +12 -26
  32. package/src/claude/outbound.ts +18 -5
  33. package/src/cli/account-api.ts +13 -1
  34. package/src/cli/account-extended.ts +17 -5
  35. package/src/cli/account-key-quota.ts +4 -1
  36. package/src/cli/account.ts +1 -1
  37. package/src/cli/capabilities-agents-routing.ts +45 -8
  38. package/src/cli/capabilities-base.ts +5 -5
  39. package/src/cli/capabilities-provider-models.ts +5 -4
  40. package/src/cli/combo-result.ts +8 -2
  41. package/src/cli/combo.ts +17 -0
  42. package/src/cli/cross-home-owner.ts +16 -2
  43. package/src/cli/help.ts +1 -1
  44. package/src/cli/loopback-family.ts +31 -0
  45. package/src/cli/message-command.ts +4 -1
  46. package/src/cli/message-remote-args.ts +69 -0
  47. package/src/cli/message-remote-command.ts +77 -0
  48. package/src/cli/provider-runtime.ts +69 -1
  49. package/src/cli/registry.ts +3 -3
  50. package/src/codex/account-pause.ts +6 -1
  51. package/src/codex/account-runtime-state.ts +5 -0
  52. package/src/codex/account-store.ts +30 -3
  53. package/src/codex/auth-api/account-list.ts +3 -0
  54. package/src/codex/auth-api/main-account-probe.ts +3 -2
  55. package/src/codex/auth-context.ts +9 -4
  56. package/src/codex/catalog/aggregation.ts +15 -0
  57. package/src/codex/catalog/auto-review.ts +2 -1
  58. package/src/codex/catalog/build-entries.ts +17 -7
  59. package/src/codex/catalog/derive-entry.ts +3 -1
  60. package/src/codex/catalog/metadata.ts +2 -0
  61. package/src/codex/catalog/model-visibility.ts +0 -2
  62. package/src/codex/catalog/ownership.ts +71 -0
  63. package/src/codex/catalog/parsing.ts +12 -3
  64. package/src/codex/catalog/retained-sync.ts +2 -1
  65. package/src/codex/catalog/routed-gather.ts +3 -1
  66. package/src/codex/catalog/routed-removal.ts +4 -4
  67. package/src/codex/convergence.ts +2 -1
  68. package/src/codex/low-quota-desktop-notifier.ts +125 -0
  69. package/src/codex/low-quota-protection.ts +57 -31
  70. package/src/codex/main-account-cache.ts +77 -3
  71. package/src/codex/main-account.ts +6 -2
  72. package/src/codex/native-residue.ts +2 -3
  73. package/src/codex/quota.ts +26 -2
  74. package/src/codex/websocket-activity.ts +161 -0
  75. package/src/codex/websocket-registry.ts +25 -1
  76. package/src/combos/failover.ts +2 -1
  77. package/src/combos/jev-model-backend.ts +30 -7
  78. package/src/combos/jev-model-request.ts +20 -0
  79. package/src/combos/jev-quota-activation.ts +153 -0
  80. package/src/combos/jev-quota-config.ts +62 -0
  81. package/src/combos/jev-quota-route.ts +26 -0
  82. package/src/combos/jev-quota.ts +88 -0
  83. package/src/combos/jev.ts +25 -12
  84. package/src/combos/types.ts +27 -0
  85. package/src/compatibility/index.ts +3 -0
  86. package/src/compatibility/openai-responses.ts +8 -1
  87. package/src/compatibility/opengateway-responses.ts +61 -0
  88. package/src/config/account-auto-pause-revisions.ts +136 -0
  89. package/src/config/diagnostics.ts +13 -0
  90. package/src/config/live-reconcile.ts +9 -0
  91. package/src/config/opencode-zen-upgrade.ts +9 -8
  92. package/src/config/persist-unlocked.ts +10 -1
  93. package/src/config/removed-opencode-free.ts +135 -0
  94. package/src/config/schema/account-auto-pause.ts +14 -0
  95. package/src/config/schema/config-schema.ts +15 -0
  96. package/src/config/schema/leaf-validators.ts +7 -0
  97. package/src/config.ts +10 -3
  98. package/src/generated/compatibility-version.json +411 -175
  99. package/src/images/loop.ts +101 -27
  100. package/src/lib/account-auto-pause-response.ts +16 -0
  101. package/src/lib/bun-runtime.ts +18 -1
  102. package/src/lib/bun-transpiler-cache-scope.d.mts +8 -0
  103. package/src/lib/bun-transpiler-cache-scope.mjs +68 -0
  104. package/src/lib/decision-quota-sink.ts +76 -0
  105. package/src/lib/request-failure-model.ts +2 -0
  106. package/src/lib/state-store-registrations.ts +25 -0
  107. package/src/link/ssh-argv.ts +3 -0
  108. package/src/messaging/envelope.ts +6 -3
  109. package/src/messaging/remote-auth.ts +83 -0
  110. package/src/messaging/remote-bridge.ts +270 -0
  111. package/src/messaging/remote-contract.ts +108 -0
  112. package/src/messaging/remote-enrollment-recovery.ts +59 -0
  113. package/src/messaging/remote-enrollment.ts +244 -0
  114. package/src/messaging/remote-envelope.ts +40 -0
  115. package/src/messaging/remote-files.ts +117 -0
  116. package/src/messaging/remote-owner.ts +169 -0
  117. package/src/messaging/remote-ports.ts +56 -0
  118. package/src/messaging/remote-process.ts +131 -0
  119. package/src/messaging/remote-replies.ts +98 -0
  120. package/src/messaging/remote-rpc-admission.ts +32 -0
  121. package/src/messaging/remote-send.ts +72 -0
  122. package/src/messaging/remote-store.ts +111 -0
  123. package/src/messaging/remote-tunnels.ts +65 -0
  124. package/src/messaging/rpc.ts +13 -1
  125. package/src/oauth/account-auto-pause-policy.ts +66 -0
  126. package/src/oauth/account-auto-pause.ts +171 -0
  127. package/src/oauth/account-quota-rank.ts +2 -1
  128. package/src/oauth/anthropic-account-refusal.ts +6 -0
  129. package/src/oauth/anthropic-model-family.ts +8 -0
  130. package/src/oauth/anthropic-model-quota.ts +2 -6
  131. package/src/oauth/api-key-pool-preserve.ts +37 -0
  132. package/src/oauth/generic-account-failover.ts +7 -4
  133. package/src/oauth/index.ts +5 -35
  134. package/src/oauth/store.ts +35 -8
  135. package/src/oauth/types.ts +2 -0
  136. package/src/plugins/upstream-hooks.ts +14 -1
  137. package/src/protocols/encoders/adapter-events.ts +5 -5
  138. package/src/protocols/encoders/messages.ts +2 -2
  139. package/src/providers/api-key-pause.ts +32 -0
  140. package/src/providers/api-key-resolve.ts +10 -4
  141. package/src/providers/api-key-selection.ts +6 -1
  142. package/src/providers/api-keys.ts +8 -4
  143. package/src/providers/claude-cli-usage.ts +5 -0
  144. package/src/providers/key-failover.ts +11 -11
  145. package/src/providers/model-rename-fields.ts +1 -0
  146. package/src/providers/opencode-zen-rate-limit.ts +12 -73
  147. package/src/providers/quota/account-cache.ts +15 -1
  148. package/src/providers/quota/anthropic-account-quota.ts +2 -1
  149. package/src/providers/quota/anthropic-cooldown-recovery.ts +22 -4
  150. package/src/providers/quota/anthropic-decision-windows.ts +47 -0
  151. package/src/providers/quota/antigravity.ts +39 -6
  152. package/src/providers/quota/report-cache.ts +14 -0
  153. package/src/providers/quota/vendor-probes-oauth.ts +3 -3
  154. package/src/providers/quota-decision-publication.ts +77 -0
  155. package/src/providers/quota-decision-snapshot.ts +145 -0
  156. package/src/providers/quota-routing-cache.ts +3 -0
  157. package/src/providers/quota-types.ts +6 -1
  158. package/src/providers/quota.ts +3 -2
  159. package/src/providers/registry/entries-extended.ts +8 -52
  160. package/src/providers/registry/model-seeds.ts +1 -1
  161. package/src/providers/registry.ts +1 -2
  162. package/src/providers/stale-vision-classification-migration.ts +1 -1
  163. package/src/responses/parser.ts +18 -10
  164. package/src/responses/reasoning-replay-cache.ts +50 -4
  165. package/src/responses/web-search-replay.ts +138 -0
  166. package/src/routing/analytics.ts +7 -15
  167. package/src/routing/compatibility/behavior.ts +4 -1
  168. package/src/server/auth-cors.ts +7 -0
  169. package/src/server/background-lifecycle.ts +2 -1
  170. package/src/server/chat-native.ts +28 -13
  171. package/src/server/claude-messages.ts +4 -2
  172. package/src/server/index/live-sideband.ts +3 -2
  173. package/src/server/index/optional-listeners.ts +6 -0
  174. package/src/server/index/serve-options.ts +11 -5
  175. package/src/server/index/websocket-handler.ts +18 -4
  176. package/src/server/index.ts +7 -7
  177. package/src/server/lifecycle.ts +13 -1
  178. package/src/server/management/combo-routes.ts +17 -2
  179. package/src/server/management/oauth-account-routes.ts +10 -1
  180. package/src/server/management/provider-completion-test.ts +289 -0
  181. package/src/server/management/provider-routes.ts +9 -1
  182. package/src/server/management/route-registry.ts +2 -0
  183. package/src/server/messages-native.ts +34 -7
  184. package/src/server/messages-shared.ts +4 -1
  185. package/src/server/request-log.ts +7 -2
  186. package/src/server/responses/account-auto-pause-recovery.ts +26 -0
  187. package/src/server/responses/adapter-continuation.ts +34 -18
  188. package/src/server/responses/adapter-delivery.ts +53 -6
  189. package/src/server/responses/adapter-dispatch.ts +104 -29
  190. package/src/server/responses/anthropic-thinking-recovery.ts +153 -0
  191. package/src/server/responses/combo-session-recall.ts +25 -0
  192. package/src/server/responses/combo-stream-preflight.ts +6 -3
  193. package/src/server/responses/combo-tool-loop-pin.ts +70 -0
  194. package/src/server/responses/compact.ts +8 -5
  195. package/src/server/responses/compaction-recovery.ts +2 -0
  196. package/src/server/responses/context-overflow.ts +141 -2
  197. package/src/server/responses/core-codex-account.ts +9 -3
  198. package/src/server/responses/core-combo.ts +34 -7
  199. package/src/server/responses/core-lifetime.ts +3 -2
  200. package/src/server/responses/core-options.ts +2 -0
  201. package/src/server/responses/core-replay.ts +5 -1
  202. package/src/server/responses/core.ts +12 -9
  203. package/src/server/responses/empty-completion-guard.ts +2 -0
  204. package/src/server/responses/fetch-helpers.ts +35 -4
  205. package/src/server/responses/jev-model-invoke.ts +13 -14
  206. package/src/server/responses/native-steering-log.ts +2 -2
  207. package/src/server/responses/passthrough-delivery.ts +3 -2
  208. package/src/server/responses/passthrough-dispatch.ts +30 -33
  209. package/src/server/responses/passthrough-execution.ts +3 -2
  210. package/src/server/responses/passthrough-observations.ts +38 -0
  211. package/src/server/responses/request-compression.ts +98 -0
  212. package/src/server/responses/request-prepare.ts +1 -0
  213. package/src/server/responses/request-transport.ts +32 -3
  214. package/src/server/responses/response-effects.ts +1 -0
  215. package/src/server/responses/response-tool-ownership.ts +21 -0
  216. package/src/server/responses/run-turn-execution.ts +2 -0
  217. package/src/server/responses/sidecar-execution.ts +59 -10
  218. package/src/server/responses/sidecar-send-budget.ts +2 -2
  219. package/src/server/responses/terminal-guard.ts +2 -0
  220. package/src/server/responses-undeclared-tool-guard.ts +1 -1
  221. package/src/server/startup-action-control.ts +2 -2
  222. package/src/server/startup-health-cache.ts +2 -2
  223. package/src/server/windows-tray-control.ts +2 -2
  224. package/src/server/ws-bridge.ts +2 -0
  225. package/src/service/definition.ts +9 -1
  226. package/src/types/account-auto-pause.ts +15 -0
  227. package/src/types/config.ts +23 -1
  228. package/src/types/opencode-zen.ts +9 -3
  229. package/src/types/provider.ts +9 -5
  230. package/src/types/request.ts +30 -4
  231. package/src/types/wire.ts +4 -2
  232. package/src/types.ts +2 -0
  233. package/src/usage/cost.ts +17 -1
  234. package/src/usage/expected-prices.ts +3 -1
  235. package/src/usage/jev-stats.ts +4 -0
  236. package/src/usage/log.ts +2 -0
  237. package/src/usage/summary.ts +16 -3
  238. package/src/usage/telemetry-contract.ts +1 -0
  239. package/src/web-search/anthropic-history.ts +59 -0
  240. package/src/web-search/loop.ts +212 -73
  241. package/src/web-search/progress-stream.ts +14 -1
  242. package/src/web-search/run-turn-loop.ts +9 -3
  243. package/src/web-search/sidecar-search-replay.ts +96 -0
  244. package/gui/dist/assets/App-ZfYbLHkB.js +0 -58
  245. package/gui/dist/assets/App-wT3mXqFz.css +0 -1
  246. package/gui/dist/assets/index-CfYDMj34.js +0 -94
@@ -21,6 +21,7 @@ import { stabilizeClaudeInstructionsForPromptCache } from "./inbound-cache-stabi
21
21
  import { decodeReasoningEnvelope, encodeReasoningEnvelope, OCX_REASONING_PREFIX } from "../responses/reasoning-envelope";
22
22
  import { inlineDocumentMarker } from "../responses/inline-document";
23
23
  import { createTranslatorBudget, type TranslatorBudget } from "../lib/translator-budget";
24
+ import { collectAnthropicCacheTtls } from "../adapters/anthropic/cache-retention";
24
25
 
25
26
 
26
27
 
@@ -390,34 +391,19 @@ export type ClaudeCacheKeySource = "metadata" | "system" | null;
390
391
  /**
391
392
  * The translated lane places its own prompt-cache breakpoints, so the caller's are dropped;
392
393
  * their TTL is the one thing worth keeping. When every caller breakpoint asks for `ttl: "1h"`
393
- * (Claude Code on a subscription marks them so) this maps to `cacheRetention: "long"`. A mixed
394
- * caller (1h prefix, 5m tail) stays on the default: "long" also makes the moving per-turn
395
- * breakpoint 1h, which bills writes the caller asked to keep at the 5m rate at the 1h rate.
396
- * Read out of body: the Responses body has no field for it.
394
+ * (Claude Code on a subscription marks them so) this maps to `cacheRetention: "long"`. A caller
395
+ * that pins any breakpoint to an explicit non-1h TTL ("5m") maps to "short": "long" would also
396
+ * make the moving per-turn breakpoint 1h and bill writes the caller asked to keep at 5m at the
397
+ * 1h rate. Breakpoints without a TTL express no preference, so they fall through to the
398
+ * destination default (1h on first-party Anthropic). Read out of body: the Responses body has
399
+ * no field for it.
397
400
  */
398
- export function callerCacheRetention(raw: unknown): "long" | undefined {
399
- if (!isRec(raw)) return undefined;
401
+ export function callerCacheRetention(raw: unknown): "long" | "short" | undefined {
400
402
  // Top-level automatic caching is a breakpoint too, and content blocks nest (tool_result,
401
- // search_result, document sources), so every block container is walked. Tool inputs and
402
- // schemas are caller data, not blocks: a `cache_control` key there is not a breakpoint.
403
- const ttls: unknown[] = [];
404
- const visit = (node: unknown, depth: number): void => {
405
- if (depth > 16) return;
406
- if (Array.isArray(node)) { for (const item of node) visit(item, depth + 1); return; }
407
- if (!isRec(node)) return;
408
- if (isRec(node.cache_control)) ttls.push(node.cache_control.ttl);
409
- for (const [key, value] of Object.entries(node)) {
410
- if (key !== "cache_control" && key !== "input" && key !== "input_schema"
411
- && (Array.isArray(value) || isRec(value))) visit(value, depth + 1);
412
- }
413
- };
414
- if (isRec(raw.cache_control)) ttls.push(raw.cache_control.ttl);
415
- visit(raw.system, 0);
416
- visit(raw.tools, 0);
417
- visit(raw.messages, 0);
418
- return ttls.length > 0 && ttls.every(ttl => ttl === "1h")
419
- ? "long"
420
- : undefined;
403
+ // search_result, document sources), so every block container is walked.
404
+ const ttls = collectAnthropicCacheTtls(raw);
405
+ if (ttls.some(ttl => ttl !== undefined && ttl !== "1h")) return "short";
406
+ return ttls.length > 0 && ttls.every(ttl => ttl === "1h") ? "long" : undefined;
421
407
  }
422
408
 
423
409
  export interface ClaudeInboundTranslation {
@@ -156,6 +156,8 @@ export function anthropicUsage(usage: unknown, webSearchRequests = 0): Rec {
156
156
  const details = isRec(u.input_tokens_details) ? u.input_tokens_details : {};
157
157
  const cached = typeof details.cached_tokens === "number" ? details.cached_tokens : 0;
158
158
  const cacheWrite = typeof details.cache_write_tokens === "number" ? details.cache_write_tokens : 0;
159
+ const cacheWrite1h = typeof details.cache_write_1h_tokens === "number"
160
+ ? Math.max(0, Math.min(details.cache_write_1h_tokens, cacheWrite)) : undefined;
159
161
  const input = typeof u.input_tokens === "number" ? u.input_tokens : 0;
160
162
  const output = typeof u.output_tokens === "number" ? u.output_tokens : 0;
161
163
  return {
@@ -163,6 +165,9 @@ export function anthropicUsage(usage: unknown, webSearchRequests = 0): Rec {
163
165
  output_tokens: output,
164
166
  cache_read_input_tokens: cached,
165
167
  cache_creation_input_tokens: cacheWrite,
168
+ ...(cacheWrite1h !== undefined
169
+ ? { cache_creation: { ephemeral_5m_input_tokens: cacheWrite - cacheWrite1h, ephemeral_1h_input_tokens: cacheWrite1h } }
170
+ : {}),
166
171
  // Only successful searches are billed/counted (Anthropic contract; Claude Code cost accounting).
167
172
  ...(webSearchRequests > 0 ? { server_tool_use: { web_search_requests: webSearchRequests } } : {}),
168
173
  };
@@ -223,16 +228,24 @@ export function sanitizeWebSearchInput(input: unknown): Rec {
223
228
  /**
224
229
  * Map a Responses `web_search_call` item to its Anthropic pair: the server_tool_use
225
230
  * input (query/queries) and the web_search_tool_result content (hits, or the error
226
- * object when the search failed). An item carrying `fetch_result` is Anthropic's
227
- * server-side web_fetch and maps to web_fetch + its web_fetch_tool_result, verbatim.
228
- * Shared by the SSE and JSON translation paths.
231
+ * object when the search failed). An item carrying `anthropic_blocks` came from Anthropic's
232
+ * own server-side call and maps back to those blocks verbatim (`srvtoolu_` id,
233
+ * `encrypted_content`, web_fetch), so the client can replay them on its next turn.
234
+ * Shared by the SSE, JSON and direct translation paths.
229
235
  */
230
236
  export function webSearchPairFromItem(item: Rec): {
231
- id: string; name: "web_search" | "web_fetch"; resultType: string; input: Rec; resultContent: unknown; completed: boolean;
237
+ id: string; name: string; resultType: string; input: Rec; resultContent: unknown; completed: boolean;
232
238
  } {
233
239
  const action = isRec(item.action) ? item.action : {};
234
- const id = typeof item.id === "string" && item.id.length > 0 ? item.id : `srvtoolu_${uuid()}`;
235
240
  const completed = item.status !== "failed";
241
+ const { use, result } = isRec(item.anthropic_blocks) ? item.anthropic_blocks : {};
242
+ if (isRec(use) && isRec(result) && typeof use.id === "string" && isRec(use.input)
243
+ && (use.name === "web_search" || use.name === "web_fetch")
244
+ && result.tool_use_id === use.id && result.type === `${use.name}_tool_result`) {
245
+ return { id: use.id, name: use.name, resultType: result.type, input: use.input, resultContent: result.content, completed };
246
+ }
247
+ const id = typeof item.id === "string" && item.id.length > 0 ? item.id : `srvtoolu_${uuid()}`;
248
+ // An item emitted before `anthropic_blocks` carried a server web_fetch as `fetch_result`.
236
249
  if (item.fetch_result !== undefined) {
237
250
  const input = { url: typeof action.url === "string" ? action.url : "" };
238
251
  return { id, name: "web_fetch", resultType: "web_fetch_tool_result", input, resultContent: item.fetch_result, completed };
@@ -16,6 +16,7 @@ import { projectApiKeyQuotaRows } from "./account-key-quota";
16
16
  import { projectAccountHealth } from "./account-next-actions";
17
17
  import type { OAuthHealthLabel } from "../oauth/health";
18
18
 
19
+ import { accountPauseDetailsSchema } from "../config/schema/account-auto-pause";
19
20
  export type AccountType = "codex" | "oauth" | "api-key";
20
21
 
21
22
  export interface AccountRow {
@@ -61,6 +62,7 @@ export interface AccountRow {
61
62
  * human listing (#2703).
62
63
  */
63
64
  paused?: boolean;
65
+ pauseDetails?: import("../types/account-auto-pause").AccountPauseDetails;
64
66
  }
65
67
 
66
68
  export type ClassifyResult = { type: AccountType } | { error: string };
@@ -342,6 +344,7 @@ interface CodexAccountDto {
342
344
  quota?: CodexQuotaDto | null;
343
345
  quotaRefresh?: unknown;
344
346
  paused?: boolean;
347
+ pauseDetails?: import("../types/account-auto-pause").AccountPauseDetails;
345
348
  }
346
349
 
347
350
  function projectQuota(quota: CodexQuotaDto | null | undefined): CodexQuotaDto | null {
@@ -415,6 +418,8 @@ export async function fetchCodexRows(
415
418
  ? a.autoSwitchThresholdOverride
416
419
  : null,
417
420
  paused: a.paused === true,
421
+ // Unknown display details are dropped defensively; the pause state above is retained.
422
+ pauseDetails: accountPauseDetailsSchema.safeParse(a.pauseDetails).data,
418
423
  ...(includeQuota ? {
419
424
  quota: projectQuota(a.quota),
420
425
  quotaRefresh: projectCodexQuotaRefreshOutcome(a.quotaRefresh),
@@ -432,6 +437,7 @@ interface OAuthAccountDto {
432
437
  healthLabel?: unknown;
433
438
  /** Present only for providers that support operator pause (generic OAuth pools). */
434
439
  paused?: boolean;
440
+ pauseDetails?: import("../types/account-auto-pause").AccountPauseDetails;
435
441
  autoSwitchThresholdOverride?: number | null;
436
442
  needsReauthReason?: "verify_account";
437
443
  autoSelectable?: boolean;
@@ -475,7 +481,8 @@ async function fetchOAuthRows(
475
481
  active: a.active ?? a.id === activeId,
476
482
  needsReauth: a.needsReauth,
477
483
  ...projectAccountHealth(a, name, a.id),
478
- ...(a.paused === true ? { paused: true } : {}),
484
+ // Unknown display details are dropped defensively; paused remains authoritative.
485
+ ...(a.paused === true ? { paused: true, pauseDetails: accountPauseDetailsSchema.safeParse(a.pauseDetails).data } : {}),
479
486
  ...(isAnthropicOAuthInstance(name) && Object.hasOwn(a, "autoSwitchThresholdOverride") ? { autoSwitchThresholdOverride: a.autoSwitchThresholdOverride } : {}),
480
487
  ...(a.needsReauthReason === "verify_account" ? { needsReauthReason: a.needsReauthReason } : {}),
481
488
  ...(name === "kiro" && typeof a.autoSelectable === "boolean"
@@ -495,6 +502,8 @@ async function fetchOAuthRows(
495
502
  }
496
503
 
497
504
  interface ApiKeyDto {
505
+ disabled?: boolean;
506
+ pauseDetails?: import("../types/account-auto-pause").AccountPauseDetails;
498
507
  id: string;
499
508
  label?: string;
500
509
  masked?: string;
@@ -523,6 +532,9 @@ async function fetchKeyRows(deps: AccountDeps, baseUrl: string, name: string, qu
523
532
  label: k.label ?? k.masked,
524
533
  masked: k.masked,
525
534
  active: k.active ?? k.id === activeId,
535
+ paused: k.disabled === true,
536
+ // Unknown display details are dropped defensively; disabled still keeps the key paused.
537
+ pauseDetails: accountPauseDetailsSchema.safeParse(k.pauseDetails).data,
526
538
  }));
527
539
  return { rows, activeId, status: 200 };
528
540
  }
@@ -260,7 +260,7 @@ function resetIso(value: number | undefined): string | null {
260
260
 
261
261
  function refreshLine(row: FamilyRows["rows"][number]): string {
262
262
  const parts = [row.id === MAIN_ID ? "main" : row.id, row.email, row.plan];
263
- if (row.paused) parts.push("paused");
263
+ if (row.paused) parts.push(row.pauseDetails ? `paused(${row.pauseDetails.reason})` : "paused");
264
264
  // Was a second quota dialect: it gated the whole block on weekly/monthly, so an account
265
265
  // reporting only a 5h window printed `quota: unknown` while `quotaParts` five lines below
266
266
  // rendered the same data correctly for the provider path (#2703). Two halves of one file
@@ -786,7 +786,7 @@ export async function cmdPriority(args: string[], deps: AccountDeps): Promise<nu
786
786
  return 0;
787
787
  }
788
788
 
789
- function resolveGenericOAuthAccountTarget(accounts: unknown[], requested: string): { id: string } | { error: string } {
789
+ function resolveGenericOAuthAccountTarget(accounts: unknown[], requested: string, kind: "OAuth" | "API key" = "OAuth"): { id: string } | { error: string } {
790
790
  const rows = accounts.filter((value): value is { id: string; alias?: unknown } =>
791
791
  typeof value === "object" && value !== null && typeof (value as { id?: unknown }).id === "string",
792
792
  );
@@ -797,10 +797,10 @@ function resolveGenericOAuthAccountTarget(accounts: unknown[], requested: string
797
797
  : rows.filter(account => typeof account.alias === "string" && account.alias.toLowerCase() === requested.toLowerCase());
798
798
  if (matches.length === 1) return { id: matches[0]!.id };
799
799
  if (matches.length > 1) return { error: `alias "${requested}" names ${matches.length} accounts; use the account id` };
800
- return { error: `Account not found: no OAuth account has the id or alias "${requested}"` };
800
+ return { error: `Account not found: no ${kind} account has the id or alias "${requested}"` };
801
801
  }
802
802
 
803
- /** Pause or resume a Codex or OAuth provider account, including Anthropic. */
803
+ /** Pause or resume a Codex, OAuth, or API-key pool account. */
804
804
  export async function cmdPause(args: string[], deps: AccountDeps, paused: boolean): Promise<number> {
805
805
  const wantsJson = flag(args, "--json");
806
806
  const name = args.shift();
@@ -839,8 +839,20 @@ export async function cmdPause(args: string[], deps: AccountDeps, paused: boolea
839
839
  return 0;
840
840
  }
841
841
 
842
+ if (classified.type === "api-key") {
843
+ const rows = await fetchRows(deps, baseUrl, name, "api-key");
844
+ if (rows.status === 0) return proxyUnreachable(rows.transportError);
845
+ if (rows.status !== 200) return apiError(rows.errorJson ?? {}, "failed to list API key accounts", rows.status);
846
+ const target = resolveGenericOAuthAccountTarget(rows.rows.map(row => ({ id: row.id, alias: row.label })), requestedId, "API key");
847
+ if ("error" in target) return usage(`Error: ${target.error}`);
848
+ const result = await apiJson(deps, baseUrl, "PUT", "/api/providers/keys/pause", { name, id: target.id, paused });
849
+ if (result.status === 0) return proxyUnreachable(result.transportError);
850
+ if (result.status !== 200) return apiError(result.json, `failed to ${verb} account`, result.status);
851
+ console.log(wantsJson ? JSON.stringify({ ok: true, provider: name, id: target.id, paused }, null, 2) : `${name}: account ${paused ? "paused" : "resumed"}`);
852
+ return 0;
853
+ }
842
854
  if (classified.type !== "codex") {
843
- return usage(`Error: ${verb} applies to the openai Codex account pool or a generic OAuth provider`);
855
+ return usage(`Error: ${verb} applies to the openai Codex account pool, a generic OAuth provider, or an API-key pool`);
844
856
  }
845
857
  const target = await resolveCodexAccountTarget(deps, baseUrl, requestedId);
846
858
  if ("networkDown" in target) return proxyUnreachable(target.transportError);
@@ -2,6 +2,7 @@
2
2
  import { parseQuotaFailureCode, type AccountQuotaFields, type ProviderQuota, type ProviderQuotaWindow, type ProviderQuotaCreditsUsd } from "../providers/quota-types";
3
3
  import { terminalSafeText } from "./runtime-api";
4
4
  import type { AccountRow, FamilyRows } from "./account-api";
5
+ import { accountPauseDetailsSchema } from "../config/schema/account-auto-pause";
5
6
 
6
7
  function record(value: unknown): Record<string, unknown> {
7
8
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Malformed API-key quota response.");
@@ -88,7 +89,9 @@ export function projectApiKeyQuotaRows(value: unknown, provider: string): Family
88
89
  }
89
90
  const masked = optionalString(row, "masked");
90
91
  return { provider, type: "api-key", id, label: optionalString(row, "label") ?? masked,
91
- masked, active: optionalBoolean(row, "active") ?? id === activeId, ...fields };
92
+ masked, active: optionalBoolean(row, "active") ?? id === activeId,
93
+ // Same projection as the plain key listing: disabled keeps the key paused, unknown details drop.
94
+ paused: row.disabled === true, pauseDetails: accountPauseDetailsSchema.safeParse(row.pauseDetails).data, ...fields };
92
95
  });
93
96
  return { rows, activeId, status: 200 };
94
97
  }
@@ -119,7 +119,7 @@ function statusText(row: AccountRow): string {
119
119
  // `paused` leads, and does NOT replace `selected`. A paused-but-selected account is the
120
120
  // state an operator most needs named -- requests route to it while the pool believes it is
121
121
  // held out -- so printing only one of the two would hide exactly the confusing case (#2703).
122
- if (row.paused) parts.push("paused");
122
+ if (row.paused) parts.push(row.pauseDetails ? `paused(${row.pauseDetails.reason})` : "paused");
123
123
  if (row.active) parts.push(row.type === "codex" ? "selected" : "active");
124
124
  if (row.needsReauth && !(row.provider === "kiro" && row.skipReason === "needs_reauth")) {
125
125
  parts.push(row.needsReauthReason === "verify_account" ? "needs-reauth(verify)" : "needs-reauth");
@@ -3,15 +3,15 @@ import type { Capability } from "./capability-types";
3
3
  export const AGENT_ROUTING_CAPABILITIES: readonly Capability[] = [
4
4
  {
5
5
  command: ["message", "sessions"],
6
- summary: "Discover already-loaded local Codex sessions without reading conversation history.",
7
- routes: [], flags: [{ name: "--json", value: "boolean", summary: "Emit a versioned local session directory." }],
6
+ summary: "Discover already-loaded Codex sessions locally or on an explicitly enrolled peer.",
7
+ routes: [], flags: [{ name: "--json", value: "boolean", summary: "Emit a versioned session directory." }, { name: "--host", value: "string", summary: "Exact enrolled peer alias or machine UUID; requires a live foreground owner." }],
8
8
  mutates: false, json: "envelope",
9
- bannerLines: ["ocx message <sub> Loaded local Codex sessions and queued peer messages (sessions|send)"],
10
- details: ["Linux/macOS Unix socket only. Uses effective CODEX_HOME; starts no daemon and returns no partial directory."],
9
+ bannerLines: ["ocx message <sub> Codex queued messages, explicit SSH peers and foreground ownership"],
10
+ details: ["Linux/macOS only. Local discovery uses the effective CODEX_HOME Unix socket; --host uses the live foreground owner's authenticated loopback route. Starts no daemon and returns no partial directory."],
11
11
  },
12
12
  {
13
13
  command: ["message", "send"],
14
- summary: "Submit one correlated peer message to an exact loaded local Codex destination.",
14
+ summary: "Submit one correlated peer message to an exact loaded Codex destination.",
15
15
  routes: [], mutates: true, json: "envelope",
16
16
  flags: [
17
17
  { name: "--thread", value: "string", summary: "Exact loaded UUID; choose this or --name." },
@@ -20,10 +20,11 @@ export const AGENT_ROUTING_CAPABILITIES: readonly Capability[] = [
20
20
  { name: "--kind", value: "string", summary: "request (default), response or notification." },
21
21
  { name: "--in-reply-to", value: "string", summary: "Request message UUID; required only for a response." },
22
22
  { name: "--json", value: "boolean", summary: "Emit a receipt with not_sent, queued or unknown status." },
23
+ { name: "--host", value: "string", summary: "Exact enrolled peer alias or machine UUID; omit for the unchanged local path." },
23
24
  ],
24
25
  details: ["Requires daemon support for experimental thread/queue/add. Sender context comes from CODEX_THREAD_ID, not an authentication claim.",
25
26
  "queued means submitted, not processed. unknown must not be replayed; no automatic retry, daemon start or thread resume.",
26
- "Exit 0: queued; 1: not sent; 3: unknown; 64: invalid usage. No remote/Claude transport or skill installation."],
27
+ "Exit 0: queued; 1: not sent; 3: unknown; 64: invalid usage. No Claude transport, native lifecycle mutation or skill installation."],
27
28
  },
28
29
  {
29
30
  command: ["advisor"],
@@ -46,6 +47,42 @@ export const AGENT_ROUTING_CAPABILITIES: readonly Capability[] = [
46
47
  ],
47
48
  },
48
49
 
50
+ {
51
+ command: ["message", "enable"], summary: "Explicitly enable private remote messaging state, without starting a listener.", routes: [],
52
+ flags: [{ name: "--port", value: "number", summary: "Unprivileged IPv4-loopback port; defaults to 39176 on first enable." },
53
+ { name: "--json", value: "boolean", summary: "Emit configuration without credentials." }], mutates: true, json: "envelope",
54
+ },
55
+ { command: ["message", "disable"], summary: "Disable remote admission and retire the foreground owner generation.", routes: [],
56
+ flags: [{ name: "--json", value: "boolean", summary: "Emit safe configuration." }], mutates: true, json: "envelope" },
57
+ { command: ["message", "status"], summary: "Read enabled, running and leased/initiated route states without activation.", routes: [],
58
+ flags: [{ name: "--json", value: "boolean", summary: "Emit safe configuration and owner observations." }], mutates: false, json: "envelope" },
59
+ { command: ["message", "serve"], summary: "Own a foreground listener and optional duplex SSH peer routes; no automatic reconnect.", routes: [],
60
+ flags: [{ name: "--host", value: "string", summary: "Exact initiating peer; repeat for at most four distinct peers." },
61
+ { name: "--json", value: "boolean", summary: "Emit the startup observation without credentials." }], mutates: true, json: "envelope" },
62
+ { command: ["message", "hosts", "probe"], summary: "Offer an SSH host fingerprint without enrollment or persistent changes.", routes: [],
63
+ flags: [{ name: "--ssh", value: "string", required: true, summary: "OpenSSH destination/alias." },
64
+ { name: "--json", value: "boolean", summary: "Emit the offered fingerprint." }], mutates: false, json: "envelope" },
65
+ { command: ["message", "hosts", "add"], usage: "ocx message hosts add <alias> --ssh <destination> --fingerprint <SHA256> [--json]",
66
+ summary: "Enroll outbound delivery and correlated return replies after SSH fingerprint confirmation.", routes: [],
67
+ flags: [{ name: "--ssh", value: "string", required: true, summary: "OpenSSH destination/alias." },
68
+ { name: "--fingerprint", value: "string", required: true, summary: "Explicitly confirmed offered SSH host-key fingerprint." },
69
+ { name: "--json", value: "boolean", summary: "Emit enrollment metadata, never credentials." }], mutates: true, json: "envelope" },
70
+ { command: ["message", "hosts", "allow-inbound"], usage: "ocx message hosts allow-inbound <alias-or-machine-uuid> [--json]",
71
+ summary: "Explicitly allow this enrolled peer to send any message kind to local loaded sessions.", routes: [],
72
+ flags: [{ name: "--json", value: "boolean", summary: "Emit the local inbound grant, without credentials." }], mutates: true, json: "envelope" },
73
+ { command: ["message", "hosts", "deny-inbound"], usage: "ocx message hosts deny-inbound <alias-or-machine-uuid> [--json]",
74
+ summary: "Restrict this peer to correlated replies on locally opened reply routes.", routes: [],
75
+ flags: [{ name: "--json", value: "boolean", summary: "Emit the narrowed local inbound grant." }], mutates: true, json: "envelope" },
76
+ { command: ["message", "hosts", "list"], summary: "List exact peer identities without credentials or listener activation.", routes: [],
77
+ flags: [{ name: "--json", value: "boolean", summary: "Emit safe peer metadata." }], mutates: false, json: "envelope" },
78
+ { command: ["message", "hosts", "remove"], usage: "ocx message hosts remove <alias-or-machine-uuid> [--transaction <uuid>] [--json]",
79
+ summary: "Revoke local admission first and report any unconfirmed remote cleanup explicitly.", routes: [],
80
+ flags: [{ name: "--transaction", value: "string", summary: "Remove only this exact enrollment; refuse a replacement transaction." },
81
+ { name: "--json", value: "boolean", summary: "Emit local and remote revocation outcomes." }], mutates: true, json: "envelope" },
82
+ { command: ["message", "hosts", "abandon"], usage: "ocx message hosts abandon --transaction <uuid> [--json]",
83
+ summary: "Abandon an exact pending local enrollment; remote cleanup remains unconfirmed (exit 3).", routes: [],
84
+ flags: [{ name: "--transaction", value: "string", required: true, summary: "Exact pending UUID from hosts list or status; never an alias." },
85
+ { name: "--json", value: "boolean", summary: "Emit local abandonment and explicit remote-cleanup guidance." }], mutates: true, json: "envelope" },
49
86
  {
50
87
  command: ["agent", "status"],
51
88
  usage: "ocx agent status [--json]",
@@ -298,10 +335,10 @@ export const AGENT_ROUTING_CAPABILITIES: readonly Capability[] = [
298
335
  },
299
336
  {
300
337
  command: ["combo","set"],
301
- usage: "ocx combo set <id> [--targets <provider/model[:weight],...> | --targets-file <FILE|->] [--strategy <failover|round-robin|random|least-used|reset-window|jev>] [--sticky <1-100|->] [--effort <low|medium|high|xhigh|max|ultra|->] [--effort-mode <fallback|force|->] [--alias <name|->] [--native-alias [on|off]] [--allow-metered-targets <on|off>] [--display-name <label|->] [--decision-provider <provider|-> | --decision-model <route|->] [--decision-timeout <ms|->] [--rename-from <id>] [--image-input <auto|disabled>] [--reasoning-effort-mode <strict|adaptive>] [--json]",
338
+ usage: "ocx combo set <id> [--targets <provider/model[:weight],...> | --targets-file <FILE|->] [--strategy <failover|round-robin|random|least-used|reset-window|jev>] [--sticky <1-100|->] [--effort <low|medium|high|xhigh|max|ultra|->] [--effort-mode <fallback|force|->] [--alias <name|->] [--native-alias [on|off]] [--allow-metered-targets <on|off>] [--display-name <label|->] [--description <text|->] [--decision-provider <provider|-> | --decision-model <route|->] [--decision-timeout <ms|->] [--decision-quota-signals <on|off|->] [--decision-quota-tiers <JSON|->] [--rename-from <id>] [--image-input <auto|disabled>] [--reasoning-effort-mode <strict|adaptive>] [--json]",
302
339
  summary: "Create, update or rename a combo.",
303
340
  routes: [{"method":"GET","path":"/api/combos"},{"method":"PUT","path":"/api/combos"}],
304
- flags: [{"name":"--allow-metered-targets","value":"string","summary":"on grants operator consent to metered targets only on a loopback-only proxy; off clears it."},{"name":"--targets","value":"string","summary":"Ordered provider/model[:weight] list; required for creation."},{"name":"--strategy","value":"string","summary":"Routing strategy."},{"name":"--sticky","value":"string","summary":"Round-robin limit 1-100; - resets."},{"name":"--effort","value":"string","summary":"Default effort; - clears."},{"name":"--effort-mode","value":"string","summary":"fallback or force; - resets to fallback."},{"name":"--alias","value":"string","summary":"Alias; - clears."},{"name":"--display-name","value":"string","summary":"Label; - clears."},{"name":"--decision-provider","value":"string","summary":"JEV provider; - clears."},{"name":"--decision-model","value":"string","summary":"JEV route; - clears; exclusive with provider."},{"name":"--decision-timeout","value":"string","summary":"JEV deadline 1000-120000 ms; - clears."},{"name":"--rename-from","value":"string","summary":"Existing combo ID to rename."},{"name":"--json","value":"boolean","summary":"Emit structured JSON."},{"name":"--native-alias","value":"string","summary":"Bare legacy flag means on; explicit on/off preserves presence, including false."},{"name":"--targets-file","value":"string","summary":"Bounded ordered target JSON array, exclusive with --targets; preserves effort/profile/last-resort metadata."},{"name":"--image-input","value":"string","summary":"auto or disabled; explicit auto clears the previous disabled choice."},{"name":"--reasoning-effort-mode","value":"string","summary":"strict or adaptive, distinct from --effort-mode fallback/force."}],
341
+ flags: [{"name":"--allow-metered-targets","value":"string","summary":"on grants operator consent to metered targets only on a loopback-only proxy; off clears it."},{"name":"--targets","value":"string","summary":"Ordered provider/model[:weight] list; required for creation."},{"name":"--strategy","value":"string","summary":"Routing strategy."},{"name":"--sticky","value":"string","summary":"Round-robin limit 1-100; - resets."},{"name":"--effort","value":"string","summary":"Default effort; - clears."},{"name":"--effort-mode","value":"string","summary":"fallback or force; - resets to fallback."},{"name":"--alias","value":"string","summary":"Alias; - clears."},{"name":"--display-name","value":"string","summary":"Label; - clears."},{"name":"--description","value":"string","summary":"Catalog row description; - clears."},{"name":"--decision-provider","value":"string","summary":"JEV provider; - clears."},{"name":"--decision-model","value":"string","summary":"JEV route; - clears; exclusive with provider."},{"name":"--decision-timeout","value":"string","summary":"JEV deadline 1000-120000 ms; - clears."},{"name":"--decision-quota-signals","value":"string","summary":"JEV advisory quota on/off; - clears."},{"name":"--decision-quota-tiers","value":"string","summary":"Partial JSON thresholds; - clears."},{"name":"--rename-from","value":"string","summary":"Existing combo ID to rename."},{"name":"--json","value":"boolean","summary":"Emit structured JSON."},{"name":"--native-alias","value":"string","summary":"Bare legacy flag means on; explicit on/off preserves presence, including false."},{"name":"--targets-file","value":"string","summary":"Bounded ordered target JSON array, exclusive with --targets; preserves effort/profile/last-resort metadata."},{"name":"--image-input","value":"string","summary":"auto or disabled; explicit auto clears the previous disabled choice."},{"name":"--reasoning-effort-mode","value":"string","summary":"strict or adaptive, distinct from --effort-mode fallback/force."}],
305
342
  mutates: true,
306
343
  json: "payload",
307
344
  details: ["create and update are aliases of set. Existing fields are read before writing; --targets replaces targets.","force overrides valid client effort and may increase cost and latency.","Only the implemented target grammar is supported; full GUI target metadata and routing-profile writes are separate tasks.","New file/flag conflicts refuse before transport. Partial edits preserve target metadata; explicit false/default values override server carry. Turning a native alias off may also require clearing its incompatible alias.","Read/merge/write uses one target and remains an upsert without revision CAS. Saved-but-incomplete catalog outcomes return nonzero with their receipt."],
@@ -340,18 +340,18 @@ export const CAPABILITIES: readonly Capability[] = [
340
340
  },
341
341
  {
342
342
  command: ["account","pause"],
343
- summary: "Exclude one account in a Codex, Anthropic or supported generic OAuth pool from automatic selection.",
344
- routes: [{"method":"GET","path":"/api/codex-auth/accounts"},{"method":"PUT","path":"/api/codex-auth/accounts/pause"},{"method":"GET","path":"/api/oauth/accounts"},{"method":"PUT","path":"/api/oauth/accounts/pause"}],
343
+ summary: "Exclude one account in a Codex, Anthropic, generic OAuth or API-key pool from automatic selection.",
344
+ routes: [{"method":"GET","path":"/api/codex-auth/accounts"},{"method":"PUT","path":"/api/codex-auth/accounts/pause"},{"method":"GET","path":"/api/oauth/accounts"},{"method":"PUT","path":"/api/oauth/accounts/pause"},{"method":"GET","path":"/api/providers/keys"},{"method":"PUT","path":"/api/providers/keys/pause"}],
345
345
  flags: [{"name":"--json","value":"boolean","summary":"Emit the pause result as JSON."}],
346
346
  mutates: true,
347
347
  json: "envelope",
348
- details: ["Codex pause unbinds pinned threads and selects a fallback when possible; with no fallback, a paused-but-selected Codex account still receives requests. Anthropic and generic OAuth pause exclude the account from new requests, failover and refresh, and an all-paused pool answers 403. Credentials and health are preserved; already-sent turns are not cancelled."],
348
+ details: ["Codex pause unbinds pinned threads and selects a fallback when possible; with no fallback, the paused account cannot dispatch until resumed. Anthropic and generic OAuth pause exclude the account from new requests, failover and refresh, and an all-paused pool answers 403. Credentials and health are preserved; already-sent turns are not cancelled."],
349
349
  usage: "ocx account pause <provider> <account-id|alias|main> [--json]",
350
350
  },
351
351
  {
352
352
  command: ["account","resume"],
353
- summary: "Return a paused account to a Codex, Anthropic or supported generic OAuth pool.",
354
- routes: [{"method":"GET","path":"/api/codex-auth/accounts"},{"method":"PUT","path":"/api/codex-auth/accounts/pause"},{"method":"GET","path":"/api/oauth/accounts"},{"method":"PUT","path":"/api/oauth/accounts/pause"}],
353
+ summary: "Return a paused account to a Codex, Anthropic, generic OAuth or API-key pool.",
354
+ routes: [{"method":"GET","path":"/api/codex-auth/accounts"},{"method":"PUT","path":"/api/codex-auth/accounts/pause"},{"method":"GET","path":"/api/oauth/accounts"},{"method":"PUT","path":"/api/oauth/accounts/pause"},{"method":"GET","path":"/api/providers/keys"},{"method":"PUT","path":"/api/providers/keys/pause"}],
355
355
  flags: [{"name":"--json","value":"boolean","summary":"Emit the resume result as JSON."}],
356
356
  mutates: true,
357
357
  json: "envelope",
@@ -49,10 +49,11 @@ export const PROVIDER_MODEL_CAPABILITIES: readonly Capability[] = [
49
49
  details: ["At least one edit is required; only supplied fields are patched. update is an existing alias. Use provider show only for local state, not as proof of the live write.","Management errors use safe fixed provider diagnostics; catalog failures retain the saved receipt and a nonzero exit."],
50
50
  },
51
51
  {
52
- command: ["provider", "test"], summary: "Probe a provider's live model discovery connectivity.",
53
- usage: "ocx provider test <name> [--json]", routes: [{ method: "POST", path: "/api/providers/test" }], mutates: true, json: "payload",
54
- flags: [{ name: "--json", value: "boolean", summary: "Emit the probe result." }],
55
- details: ["May contact the upstream provider; requires operator intent to probe. Static catalogs can return applicable:false; failed connectivity exits nonzero."],
52
+ command: ["provider", "test"], summary: "Probe discovery connectivity or explicitly test one model completion.",
53
+ usage: "ocx provider test <name> [--model <id> [--yes]] [--json]",
54
+ routes: [{ method: "POST", path: "/api/providers/test" }, { method: "POST", path: "/api/providers/test-completion" }], mutates: true, json: "payload",
55
+ flags: [{ name: "--model", value: "string", summary: "Run one bounded completion for this model instead of discovery." }, { name: "--yes", value: "boolean", summary: "Confirm completion quota/cost; requires --model. Mandatory with --json or non-interactive input." }, { name: "--json", value: "boolean", summary: "Emit the probe result; completion warnings go to stderr." }],
56
+ details: ["Without --model, discovery behavior is unchanged; static catalogs can return applicable:false. Completion tests print a quota/cost warning and require a default-No terminal confirmation or --yes; cancellation exits 1 without a request.", "Completion output includes TTFT, total latency, upstream/wire model metadata and comparison. Metadata is not proof of model identity; mismatch still exits 0. Failed tests exit 1; non-applicable results exit 0."],
56
57
  },
57
58
  {
58
59
  command: ["provider", "quota"], summary: "Read provider quota reports, optionally refreshing them.",
@@ -1,3 +1,4 @@
1
+ import { normalizeJevQuotaTiers } from "../combos/jev-quota-config";
1
2
  import { parseComboTargets } from "./combo-input";
2
3
  import { printCatalogResult } from "./catalog-command-result";
3
4
 
@@ -7,7 +8,7 @@ const ENUMS: Readonly<Record<string, readonly (string | null)[]>> = {
7
8
  defaultEffortMode: ["fallback", "force"], reasoningEffortMode: ["strict", "adaptive"],
8
9
  imageInput: ["auto", "disabled"], cooldownWaitPolicy: ["before-last-resort"],
9
10
  };
10
- const STRINGS = new Set(["alias", "displayName", "decisionProvider", "decisionModel"]);
11
+ const STRINGS = new Set(["alias", "displayName", "description", "decisionProvider", "decisionModel"]);
11
12
  const RANGES: Readonly<Record<string, readonly [number, number]>> = {
12
13
  stickyLimit: [1, 100], cooldownMs: [1, 600_000], waitForCooldownMs: [0, 600_000],
13
14
  decisionTimeoutMs: [1_000, 120_000],
@@ -33,7 +34,12 @@ function publicCombo(value: unknown): Record<string, unknown> {
33
34
  } else if (Object.hasOwn(RANGES, key)) {
34
35
  const [min, max] = RANGES[key]!;
35
36
  if (typeof child !== "number" || !Number.isSafeInteger(child) || child < min || child > max) invalid();
36
- } else if (key === "nativeAlias" || key === "allowMeteredTargets") {
37
+ } else if (key === "decisionQuotaTiers") {
38
+ const tiers = normalizeJevQuotaTiers(child);
39
+ if (!tiers) invalid();
40
+ result[key] = tiers;
41
+ continue;
42
+ } else if (key === "nativeAlias" || key === "allowMeteredTargets" || key === "decisionQuotaSignals") {
37
43
  if (typeof child !== "boolean") invalid();
38
44
  } else invalid();
39
45
  result[key] = child;
package/src/cli/combo.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { normalizeJevQuotaTiers } from "../combos/jev-quota-config";
1
2
  import { JEV_DECISION_TIMEOUT_MAX_MS, JEV_DECISION_TIMEOUT_MIN_MS } from "../combos/types";
2
3
  import {
3
4
  CliUsageError,
@@ -24,8 +25,10 @@ const USAGE = `Usage:
24
25
  [--effort <low|medium|high|xhigh|max|ultra|->] [--effort-mode <fallback|force|->]
25
26
  (force overrides valid client effort and can increase cost/latency) [--alias <name|->]
26
27
  [--native-alias [on|off]] [--allow-metered-targets <on|off>] [--display-name <label|->]
28
+ [--description <text|->]
27
29
  [--image-input <auto|disabled>] [--reasoning-effort-mode <strict|adaptive>]
28
30
  [--decision-provider <provider|-> | --decision-model <route|->] [--decision-timeout <ms|->]
31
+ [--decision-quota-signals <on|off|->] [--decision-quota-tiers <JSON|->]
29
32
  (jev only; the provider must be a configured jev-decision row)
30
33
  [--rename-from <id>] [--json]
31
34
  ocx combo stats <id> [--range <7d|30d|all>] [--json]
@@ -80,6 +83,7 @@ async function show(argv: string[], deps: RuntimeApiDeps): Promise<void> {
80
83
  printData(combo, wantsJson);
81
84
  }
82
85
 
86
+ /** Handle `ocx combo set`: parse the combo id and flags (including `--decision-quota-signals` and `--decision-quota-tiers`), then create or update the combo through the management API. */
83
87
  async function set(argv: string[], deps: RuntimeApiDeps): Promise<number> {
84
88
  const args = [...argv];
85
89
  const id = args.shift()?.trim();
@@ -118,6 +122,7 @@ async function set(argv: string[], deps: RuntimeApiDeps): Promise<number> {
118
122
  const alias = takeOption(args, "--alias");
119
123
 
120
124
  const displayName = takeOption(args, "--display-name");
125
+ const description = takeOption(args, "--description");
121
126
  const decisionProvider = takeOption(args, "--decision-provider");
122
127
  const decisionModel = takeOption(args, "--decision-model");
123
128
  if (decisionModel !== undefined && decisionModel !== "-" && decisionProvider !== undefined && decisionProvider !== "-") {
@@ -129,6 +134,13 @@ async function set(argv: string[], deps: RuntimeApiDeps): Promise<number> {
129
134
  if (decisionProvider !== undefined && decisionProvider !== "-" && strategy !== "jev") {
130
135
  throw new CliUsageError("--decision-provider applies only to the jev strategy", USAGE);
131
136
  }
137
+ const quotaSignals = takeOption(args, "--decision-quota-signals");
138
+ const quotaTiers = takeOption(args, "--decision-quota-tiers");
139
+ if (quotaSignals !== undefined && !["on", "off", "-"].includes(quotaSignals)) throw new CliUsageError("--decision-quota-signals must be on, off or -", USAGE);
140
+ let parsedQuotaTiers: ReturnType<typeof normalizeJevQuotaTiers> | null;
141
+ try { parsedQuotaTiers = quotaTiers === undefined || quotaTiers === "-" ? null : normalizeJevQuotaTiers(JSON.parse(quotaTiers)); } catch { throw new CliUsageError("invalid --decision-quota-tiers JSON", USAGE); }
142
+ if (quotaTiers !== undefined && quotaTiers !== "-" && !parsedQuotaTiers) throw new CliUsageError("quota tiers must be finite 0..100 and strictly ascending", USAGE);
143
+ if (strategy !== "jev" && (quotaSignals !== undefined && quotaSignals !== "-" || quotaTiers !== undefined && quotaTiers !== "-")) throw new CliUsageError("quota settings apply only to jev", USAGE);
132
144
  const decisionTimeout = takeOption(args, "--decision-timeout");
133
145
  let decisionTimeoutMs: number | null | undefined;
134
146
  if (decisionTimeout !== undefined) {
@@ -164,15 +176,20 @@ async function set(argv: string[], deps: RuntimeApiDeps): Promise<number> {
164
176
  if (additional.imageInput !== undefined) combo.imageInput = additional.imageInput;
165
177
  if (additional.reasoningEffortMode !== undefined) combo.reasoningEffortMode = additional.reasoningEffortMode;
166
178
  if (displayName !== undefined) combo.displayName = displayName === "-" ? "" : displayName;
179
+ if (description !== undefined) combo.description = description === "-" ? "" : description;
167
180
  if (decisionProvider !== undefined) combo.decisionProvider = decisionProvider === "-" ? null : decisionProvider;
168
181
  if (decisionModel !== undefined) combo.decisionModel = decisionModel === "-" ? null : decisionModel;
169
182
  if (decisionProvider !== undefined && decisionProvider !== "-") combo.decisionModel = null;
170
183
  if (decisionModel !== undefined && decisionModel !== "-") combo.decisionProvider = null;
171
184
  if (decisionTimeoutMs !== undefined) combo.decisionTimeoutMs = decisionTimeoutMs;
185
+ if (quotaSignals !== undefined) combo.decisionQuotaSignals = quotaSignals === "-" ? null : quotaSignals === "on";
186
+ if (quotaTiers !== undefined) combo.decisionQuotaTiers = parsedQuotaTiers;
172
187
  if (strategy !== "jev") {
173
188
  delete combo.decisionProvider;
174
189
  delete combo.decisionModel;
175
190
  delete combo.decisionTimeoutMs;
191
+ delete combo.decisionQuotaSignals;
192
+ delete combo.decisionQuotaTiers;
176
193
  }
177
194
  const current = partialCurrent ?? await runtimeRequest<{ combos?: ComboRow[] }>("/api/combos", { redirect: "error" }, deps);
178
195
  const existing = (current.combos ?? []).find(row => row.id === (renameFrom ?? id));
@@ -26,6 +26,7 @@ import {
26
26
  } from "../server/proxy-liveness";
27
27
  import { directLocalHttpFetch } from "../server/direct-local-http";
28
28
  import type { RuntimePortState } from "../config/process-state";
29
+ import { loopbackFamilyAvailability } from "./loopback-family";
29
30
 
30
31
  const MAX_HINT_BYTES = 256 * 1024;
31
32
  // Far above any real Grok config; discovery must not stall startup when Grok sync is off.
@@ -128,13 +129,19 @@ function loopbackPort(raw: string | null): number | null {
128
129
  }
129
130
  }
130
131
 
132
+ type CrossHomeOwnerOptions = {
133
+ homeDir?: string;
134
+ io?: LivenessIo;
135
+ loopbackAvailabilityFn?: typeof loopbackFamilyAvailability;
136
+ };
137
+
131
138
  /** Returns only a different process with an identity-checked /healthz response. */
132
- export async function findCrossHomeOwner(options: { homeDir?: string; io?: LivenessIo } = {}): Promise<number | null> {
139
+ export async function findCrossHomeOwner(options: CrossHomeOwnerOptions = {}): Promise<number | null> {
133
140
  const verdict = await findCrossHomeOwnerDetailed(options);
134
141
  return verdict.kind === "owner" ? verdict.port : null;
135
142
  }
136
143
 
137
- export async function findCrossHomeOwnerDetailed(options: { homeDir?: string; io?: LivenessIo } = {}): Promise<CrossHomeOwnerVerdict> {
144
+ export async function findCrossHomeOwnerDetailed(options: CrossHomeOwnerOptions = {}): Promise<CrossHomeOwnerVerdict> {
138
145
  const io = options.io ?? {};
139
146
  const nowFn = io.nowFn ?? Date.now;
140
147
  const timeoutMs = io.timeoutMs ?? START_OWNERSHIP_LIVENESS.timeoutMs ?? 1_500;
@@ -205,6 +212,8 @@ export async function findCrossHomeOwnerDetailed(options: { homeDir?: string; io
205
212
  }
206
213
 
207
214
  const queue = [...candidates];
215
+ const availability = new Map<string, ReturnType<typeof loopbackFamilyAvailability>>();
216
+ const checkAvailability = options.loopbackAvailabilityFn ?? loopbackFamilyAvailability;
208
217
  const probed = new Set<number>();
209
218
  for (const port of queue) {
210
219
  if (probed.has(port)) continue;
@@ -225,6 +234,11 @@ export async function findCrossHomeOwnerDetailed(options: { homeDir?: string; io
225
234
  noteIndeterminate(port, "ownership discovery ran out of its shared time budget");
226
235
  break;
227
236
  }
237
+ // Disabled loopback IPv6 can route ::1 off-host and time out. An ephemeral
238
+ // local bind proves address absence without treating an available family's
239
+ // timeout as empty. Unknown bind failures retain the existing probes.
240
+ if (!availability.has(hostname)) availability.set(hostname, checkAvailability(hostname));
241
+ if (availability.get(hostname) === "unavailable") continue;
228
242
  const identity = await proxyIdentityAt(port, { hostname }, probeIo);
229
243
  if (identity === null) {
230
244
  // A null identity is not "free": distinguish a unanimous connect refusal
package/src/cli/help.ts CHANGED
@@ -79,7 +79,7 @@ Usage:
79
79
  ocx alias <sub> Short names for providers and models (list, set, rm, defaults)
80
80
  ocx combo <sub> Combo routing strategies and failover
81
81
  ocx agent <sub> Subagents, injection, effort caps, and sidecars
82
- ocx message <sub> Loaded local Codex sessions and queued peer messages (sessions|send)
82
+ ocx message <sub> Codex queued messages, explicit SSH peers and foreground ownership
83
83
  ocx effort [sub] Inspect and configure reasoning effort caps and defaults
84
84
  ocx observe <sub> Logs, usage, storage, memory, and debug data
85
85
  ocx inspect <sub> Effective config, catalog, analytics, pacing, client-config
@@ -0,0 +1,31 @@
1
+ /** A failed connect cannot distinguish an absent loopback address from a slow owner. */
2
+ export type LoopbackFamilyAvailability = "available" | "unavailable" | "unknown";
3
+
4
+ function bindLoopback(hostname: string): { stop(): void } {
5
+ return Bun.listen({ hostname, port: 0, socket: { data(socket) { socket.end(); } } });
6
+ }
7
+
8
+ /**
9
+ * Ask the kernel whether the concrete local address can be bound, independently of
10
+ * routing/connect timeouts. Only address/family absence permits skipping discovery;
11
+ * permissions, resource exhaustion and other failures leave owner evidence unknown.
12
+ * The ephemeral listener is closed synchronously and never accepts client traffic.
13
+ */
14
+ export function loopbackFamilyAvailability(
15
+ hostname: string,
16
+ bindFn: (hostname: string) => { stop(): void } = bindLoopback,
17
+ ): LoopbackFamilyAvailability {
18
+ const address = hostname === "[::1]" ? "::1" : hostname;
19
+ if (address !== "::1" && address !== "127.0.0.1") return "unknown";
20
+ let listener: { stop(): void };
21
+ try {
22
+ listener = bindFn(address);
23
+ } catch (error) {
24
+ const code = (error as { code?: unknown } | null)?.code;
25
+ return code === "EADDRNOTAVAIL" || code === "EAFNOSUPPORT"
26
+ || code === "EPROTONOSUPPORT"
27
+ ? "unavailable" : "unknown";
28
+ }
29
+ listener.stop();
30
+ return "available";
31
+ }