@bitkyc08/opencodex 2.64.0-preview.20260923 → 2.65.0-preview.20260925

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 (208) hide show
  1. package/README.md +28 -25
  2. package/bin/ocx.mjs +16 -2
  3. package/gui/dist/assets/App-8NMiZxT0.css +1 -0
  4. package/gui/dist/assets/App-CsYvvpr3.js +50 -0
  5. package/gui/dist/assets/Tray-DUvc_Wul.js +1 -0
  6. package/gui/dist/assets/{index-DiBRuK-d.css → index--EWgGQvZ.css} +1 -1
  7. package/gui/dist/assets/{index-BEq4OCOz.js → index-BB0iHG8-.js} +12 -12
  8. package/gui/dist/assets/tray-data-f0lTZ4sF.js +1 -0
  9. package/gui/dist/index.html +2 -2
  10. package/package.json +1 -4
  11. package/src/adapters/anthropic.ts +30 -1
  12. package/src/adapters/claude-cli/adapter.ts +218 -0
  13. package/src/adapters/claude-cli/profiles.ts +38 -0
  14. package/src/adapters/codebuddy/profiles.ts +2 -0
  15. package/src/adapters/coding-agent/profile.ts +12 -4
  16. package/src/adapters/coding-agent/turn.ts +16 -6
  17. package/src/adapters/command-code-tool-text.ts +133 -23
  18. package/src/adapters/cursor/catalog.ts +4 -8
  19. package/src/adapters/cursor/discovery.ts +10 -5
  20. package/src/adapters/cursor/effort-map.ts +2 -6
  21. package/src/adapters/cursor/envelope-echo.ts +10 -5
  22. package/src/adapters/cursor/request-builder.ts +9 -4
  23. package/src/adapters/cursor/thread-continuity.ts +53 -0
  24. package/src/adapters/cursor.ts +29 -13
  25. package/src/adapters/devin/cloud-direct/chat.ts +3 -1
  26. package/src/adapters/google-tool-schema.ts +26 -3
  27. package/src/adapters/identity.ts +214 -2
  28. package/src/adapters/openai-chat/passthrough.ts +1 -0
  29. package/src/adapters/openai-chat/serialized-tool-call-content.ts +569 -0
  30. package/src/adapters/openai-chat.ts +43 -15
  31. package/src/adapters/openai-responses/canonical-forward.ts +17 -10
  32. package/src/adapters/openai-responses/passthrough.ts +19 -4
  33. package/src/adapters/openai-responses/request-strips.ts +25 -0
  34. package/src/adapters/openai-responses/web-search.ts +22 -4
  35. package/src/adapters/qoder/profiles.ts +2 -0
  36. package/src/adapters/registry.ts +8 -0
  37. package/src/adapters/run-turn-queue.ts +7 -0
  38. package/src/bridge/response-json.ts +6 -4
  39. package/src/bridge/sse.ts +7 -5
  40. package/src/claude/alias.ts +73 -26
  41. package/src/claude/context-windows.ts +15 -6
  42. package/src/claude/desktop-3p-library.ts +3 -2
  43. package/src/claude/desktop-3p.ts +1 -1
  44. package/src/claude/desktop-first-party.ts +63 -22
  45. package/src/claude/desktop-picker-profile.ts +302 -0
  46. package/src/claude/desktop-picker.ts +365 -0
  47. package/src/claude/desktop-risk.ts +20 -0
  48. package/src/claude/gateway-cache.ts +13 -4
  49. package/src/claude/inbound.ts +5 -2
  50. package/src/claude/intercept/connect-proxy.ts +106 -29
  51. package/src/claude/intercept/local-ca.ts +76 -18
  52. package/src/claude/intercept/model-bindings.ts +145 -0
  53. package/src/claude/intercept/picker-bootstrap.ts +141 -0
  54. package/src/claude/intercept/picker-ca.ts +126 -0
  55. package/src/claude/intercept/picker-listener.ts +213 -0
  56. package/src/claude/intercept/picker-models.ts +101 -0
  57. package/src/claude/intercept/picker-runtime.ts +330 -0
  58. package/src/claude/intercept/picker-trust.ts +125 -0
  59. package/src/claude/intercept/runtime.ts +149 -2
  60. package/src/claude/model-info.ts +18 -4
  61. package/src/cli/agent.ts +16 -2
  62. package/src/cli/capabilities.ts +71 -2
  63. package/src/cli/claude-desktop.ts +319 -33
  64. package/src/cli/claude.ts +15 -7
  65. package/src/cli/codex-shim-autorestore.ts +1 -0
  66. package/src/cli/dispatch.ts +29 -23
  67. package/src/cli/effort.ts +16 -6
  68. package/src/cli/ensure-desired-integrations.ts +29 -4
  69. package/src/cli/ready.ts +1 -1
  70. package/src/cli/registry.ts +8 -0
  71. package/src/cli/restart-scope.ts +9 -1
  72. package/src/cli/runtime-api.ts +17 -0
  73. package/src/cli/system-command.ts +11 -12
  74. package/src/client/connect.ts +12 -2
  75. package/src/client/state.ts +39 -1
  76. package/src/clients/config-export.ts +57 -9
  77. package/src/codex/auth-context.ts +51 -11
  78. package/src/codex/catalog/derive-entry.ts +5 -5
  79. package/src/codex/catalog/gather-capture.ts +25 -6
  80. package/src/codex/catalog/metadata.ts +3 -3
  81. package/src/codex/catalog/parsing.ts +39 -1
  82. package/src/codex/catalog/provider-models.ts +8 -6
  83. package/src/codex/catalog/retained-sync.ts +17 -7
  84. package/src/codex/catalog/sync.ts +2 -2
  85. package/src/codex/desktop-app-restart.ts +8 -0
  86. package/src/codex/desktop-switches.ts +9 -1
  87. package/src/codex/home.ts +43 -4
  88. package/src/codex/inject/config-toml.ts +137 -0
  89. package/src/codex/inject/plan.ts +23 -0
  90. package/src/codex/inject/remove.ts +21 -1
  91. package/src/codex/inject.ts +63 -8
  92. package/src/codex/journal.ts +36 -0
  93. package/src/codex/main-account-hard-lock.ts +14 -1
  94. package/src/codex/main-account-policy-wait.ts +109 -0
  95. package/src/codex/native-profile-startup.ts +24 -6
  96. package/src/codex/native-residue.ts +28 -10
  97. package/src/codex/quota-types.ts +8 -1
  98. package/src/codex/quota.ts +49 -7
  99. package/src/codex/runtime.ts +31 -10
  100. package/src/combos/failover.ts +7 -6
  101. package/src/combos/resolve.ts +91 -14
  102. package/src/combos/types.ts +18 -1
  103. package/src/config/load-degrade.ts +2 -0
  104. package/src/config/schema/config-schema.ts +21 -2
  105. package/src/config/schema/leaf-validators.ts +6 -3
  106. package/src/config/subagent-models.ts +3 -1
  107. package/src/generated/compatibility-version.json +275 -163
  108. package/src/images/artifacts.ts +14 -0
  109. package/src/integrations/owned-refresh.ts +1 -1
  110. package/src/integrations/ownership-policy.ts +32 -1
  111. package/src/integrations/state.ts +3 -1
  112. package/src/integrations/writer.ts +9 -0
  113. package/src/lib/config-ownership.ts +3 -0
  114. package/src/lib/errors.ts +87 -0
  115. package/src/lib/package-tree-integrity.ts +1 -1
  116. package/src/lib/request-execution-budget.ts +42 -14
  117. package/src/lib/request-resend-gate.ts +4 -3
  118. package/src/lib/retry-delay.ts +7 -1
  119. package/src/lib/tool-envelope-echo-filter.ts +236 -0
  120. package/src/lib/upstream-retry.ts +20 -12
  121. package/src/oauth/index.ts +1 -0
  122. package/src/oauth/kiro-credentials.ts +14 -1
  123. package/src/oauth/login-cli.ts +1 -0
  124. package/src/providers/derive.ts +4 -0
  125. package/src/providers/fast-opt-in.ts +31 -0
  126. package/src/providers/model-rename-fields.ts +2 -0
  127. package/src/providers/provider-id-rewrite.ts +2 -0
  128. package/src/providers/quota/vendor-probes-key.ts +8 -2
  129. package/src/providers/registry/entries-core.ts +45 -2
  130. package/src/providers/registry/entries-extended.ts +99 -12
  131. package/src/providers/registry/model-ids.ts +2 -0
  132. package/src/providers/registry/types.ts +7 -1
  133. package/src/providers/resolved-model-policy.ts +11 -4
  134. package/src/providers/service-tier.ts +10 -3
  135. package/src/responses/code-mode-helper-compat.ts +3 -0
  136. package/src/responses/parser.ts +45 -3
  137. package/src/router.ts +6 -0
  138. package/src/server/auth-cors.ts +8 -0
  139. package/src/server/chat-completions.ts +3 -1
  140. package/src/server/claude-messages.ts +69 -14
  141. package/src/server/grok-upstream-envelope-echo.ts +120 -0
  142. package/src/server/index/claude-intercept-lifecycle.ts +24 -0
  143. package/src/server/index/serve-options.ts +2 -2
  144. package/src/server/index.ts +7 -0
  145. package/src/server/management/agent-settings-routes.ts +204 -112
  146. package/src/server/management/claude-desktop-picker-routes.ts +128 -0
  147. package/src/server/management/combo-routes.ts +59 -2
  148. package/src/server/management/config-routes.ts +81 -39
  149. package/src/server/management/context.ts +3 -0
  150. package/src/server/management/native-integration-routes.ts +169 -93
  151. package/src/server/management/provider-routes.ts +46 -4
  152. package/src/server/management/route-registry.ts +10 -0
  153. package/src/server/management/routing-profile-routes.ts +9 -0
  154. package/src/server/management/sidebar-routes.ts +52 -0
  155. package/src/server/management-api.ts +2 -0
  156. package/src/server/proxy-liveness.ts +38 -3
  157. package/src/server/request-log-account-rotation.ts +53 -0
  158. package/src/server/request-log.ts +3 -16
  159. package/src/server/responses/adapter-dispatch.ts +17 -1
  160. package/src/server/responses/agent-task-recovery.ts +49 -26
  161. package/src/server/responses/codex-ws-exchange.ts +18 -7
  162. package/src/server/responses/codex-ws-wire.ts +27 -4
  163. package/src/server/responses/core-combo.ts +21 -3
  164. package/src/server/responses/core-normalize.ts +7 -0
  165. package/src/server/responses/core-opaque-recovery.ts +3 -2
  166. package/src/server/responses/encrypted-payload.ts +15 -14
  167. package/src/server/responses/fetch-helpers.ts +1 -1
  168. package/src/server/responses/input-admission.ts +10 -6
  169. package/src/server/responses/native-response-control.ts +2 -2
  170. package/src/server/responses/passthrough-delivery.ts +182 -12
  171. package/src/server/responses/passthrough-dispatch.ts +102 -44
  172. package/src/server/responses/policy-fallback.ts +3 -0
  173. package/src/server/responses/policy-refusal.ts +67 -0
  174. package/src/server/responses/request-send-budget.ts +4 -0
  175. package/src/server/responses/run-turn-execution.ts +42 -11
  176. package/src/server/responses/ws-upstream.ts +2 -1
  177. package/src/server/system-env-shell.ts +0 -1
  178. package/src/server/system-env.ts +25 -4
  179. package/src/service/cli.ts +34 -3
  180. package/src/tray/assets/opencodex-tray-offline-update.ico +0 -0
  181. package/src/tray/assets/opencodex-tray-online-update.ico +0 -0
  182. package/src/tray/assets/opencodex-tray-warning-update.ico +0 -0
  183. package/src/tray/windows-tray.ps1 +188 -7
  184. package/src/tray/windows.ts +23 -2
  185. package/src/types/config.ts +52 -12
  186. package/src/types/provider.ts +22 -5
  187. package/src/types/tools.ts +11 -5
  188. package/src/types.ts +1 -0
  189. package/src/update/async-check.ts +109 -0
  190. package/src/update/badge.ts +11 -17
  191. package/src/update/check-types.ts +9 -0
  192. package/src/update/desktop-badge.ts +102 -0
  193. package/src/update/index.ts +48 -8
  194. package/src/update/install-detection.d.mts +29 -2
  195. package/src/update/install-detection.mjs +213 -7
  196. package/src/update/job.ts +17 -12
  197. package/src/update/notify.ts +16 -11
  198. package/src/update/pnpm-owner-worker.ts +12 -0
  199. package/src/update/refresh-scheduler.ts +141 -0
  200. package/src/usage/cost.ts +31 -4
  201. package/src/usage/expected-prices.ts +57 -0
  202. package/assets/download-linux.svg +0 -10
  203. package/assets/download-macos.svg +0 -10
  204. package/assets/download-windows.svg +0 -10
  205. package/gui/dist/assets/App-D5eN1TID.js +0 -50
  206. package/gui/dist/assets/App-I5AnaSLh.css +0 -1
  207. package/gui/dist/assets/Tray-Bh_ErQDh.js +0 -1
  208. package/gui/dist/assets/usage-companion-chart-DBG_37kQ.js +0 -1
@@ -81,10 +81,11 @@ export interface OcxClaudeCodeConfig {
81
81
  */
82
82
  authModeMigratedAt?: string;
83
83
  /**
84
- * Context-window override for Claude Code/Desktop clients (devlog 136 B6):
85
- * injected as CLAUDE_CODE_MAX_CONTEXT_TOKENS + DISABLE_COMPACT=1 (the official
86
- * env pair — recognized claude-shaped ids need both). WARNING: DISABLE_COMPACT
87
- * turns off auto-compaction. Unset = client defaults.
84
+ * Context-window override for Claude Code/Desktop clients (devlog 136 B6).
85
+ * Injected as CLAUDE_CODE_MAX_CONTEXT_TOKENS only. Current ocx-claude aliases do
86
+ * not start with claude-, so Claude Code 2.1.278 honors the window without
87
+ * DISABLE_COMPACT. A persisted claude-ocx id is still claude-shaped and keeps
88
+ * the 200k accounting until the picker selects the new id. Unset = client defaults.
88
89
  */
89
90
  maxContextTokens?: number;
90
91
  /**
@@ -131,8 +132,7 @@ export interface OcxClaudeCodeConfig {
131
132
  * (Claude Code then accounts 1M) and CLAUDE_CODE_AUTO_COMPACT_WINDOW is injected
132
133
  * so compaction fires at the real budget. 2.1.207 semantics (binary-verified):
133
134
  * effective compact window = min(believed window, env) — one global env behaves
134
- * like a per-model floor. Default: enabled. Inert while maxContextTokens is set
135
- * (the legacy DISABLE_COMPACT pair takes rule-1 precedence in the CLI).
135
+ * like a per-model floor. Default: enabled. Inert while maxContextTokens is set.
136
136
  */
137
137
  autoContext?: boolean;
138
138
  /** Compact-window tokens for auto-context. Default 829_800 (AUTO_COMPACT_WINDOW_DEFAULT). */
@@ -142,8 +142,19 @@ export interface OcxClaudeCodeConfig {
142
142
  * traffic without any `ANTHROPIC_BASE_URL` rewrite (src/claude/intercept). Claude Code reaches
143
143
  * it via `HTTPS_PROXY`/`NODE_EXTRA_CA_CERTS` in its settings env. Default: enabled on a
144
144
  * hub; the proxy port defaults to the public port + 100.
145
+ *
146
+ * `modelMap` holds first-party model bindings (src/claude/intercept/model-bindings.ts): a
147
+ * Claude Desktop Code tab picker id such as `claude-sonnet-4-6` mapped to an opencodex route in
148
+ * the Desktop route vocabulary (`provider/model`, or `native/<slug>`). Bindings apply only to
149
+ * requests that arrive through the intercept pair, overlaid on the global `modelMap`.
145
150
  */
146
- intercept?: { enabled?: boolean; port?: number };
151
+ intercept?: {
152
+ enabled?: boolean;
153
+ port?: number;
154
+ /** First-party Desktop Code-tab picker injection; unset enables it when eligible. */
155
+ picker?: boolean;
156
+ modelMap?: Record<string, string>;
157
+ };
147
158
  /**
148
159
  * Bundled-skill content elision for ROUTED (non-Anthropic) models (devlog 260712
149
160
  * 060): Skill-tool results whose skill name matches an entry here are replaced
@@ -175,10 +186,12 @@ export interface OcxClaudeCodeConfig {
175
186
  desktopProfile?: OcxClaudeDesktopProfile;
176
187
  /**
177
188
  * How Claude Desktop reaches opencodex (src/claude/desktop-first-party.ts).
178
- * `first-party` (default) leaves the app on its normal claude.ai login and redirects only the
179
- * Code tab's Claude Code process through the intercept pair via settings.json env.
180
- * `gateway` installs the third-party deployment profile (desktop-3p) for the whole app.
181
- * Unset on an install that already applied a gateway profile resolves to `gateway`.
189
+ * `gateway` (default) installs the third-party deployment profile (desktop-3p) for the whole app.
190
+ * `first-party` leaves the app on its normal claude.ai login and redirects only the Code tab's
191
+ * Claude Code process through the intercept pair via settings.json env; it sends Claude
192
+ * subscription traffic through a local interception proxy and carries an account-risk warning
193
+ * (src/claude/desktop-risk.ts). Unset: a gateway row or apply marker resolves to `gateway`, and
194
+ * first-party env that opencodex wrote resolves to `first-party` (observeClaudeDesktopMode).
182
195
  */
183
196
  desktopMode?: "first-party" | "gateway";
184
197
  /** Auto-reconcile Desktop 3P config when provider catalog changes. Default: enabled. */
@@ -498,7 +511,13 @@ export interface OcxConfig {
498
511
  * "no fast tier was requested".
499
512
  */
500
513
  ultraFastTier?: boolean;
501
- /** Stop new identity-matched main-account requests at observed 99% usage. Default off. */
514
+ /**
515
+ * Stop new identity-matched main-account requests at observed 98% usage (#5694).
516
+ *
517
+ * On by default: an absent key and `true` both enable it, and only an explicit `false`
518
+ * opts out. While it blocks, the main account's Luna Reserve cannot activate, so an operator
519
+ * who wants Reserve has to turn the setting off rather than delete the key.
520
+ */
502
521
  codexMainAccountHardLock?: boolean;
503
522
  /** Explicit top-level deletion intent used by stale whole-config rebases. */
504
523
  configRebaseProvenance?: OcxConfigRebaseProvenance | Record<string, unknown>;
@@ -1134,11 +1153,20 @@ export type OcxComboDefaultEffortMode = "fallback" | "force";
1134
1153
  */
1135
1154
  export type OcxComboReasoningEffortMode = "strict" | "adaptive";
1136
1155
 
1156
+ /** Policy for how target cooldowns interact with `lastResort` targets (#5691). */
1157
+ export type OcxComboCooldownWaitPolicy = "before-last-resort";
1158
+
1137
1159
  export interface OcxComboTarget {
1138
1160
  provider: string;
1139
1161
  model: string;
1140
1162
  /** Relative target weight for round-robin batches and random selection. Default 1; valid range 1..10000. */
1141
1163
  weight?: number;
1164
+ /**
1165
+ * Marks an emergency-only target. Inert unless the combo sets
1166
+ * `cooldownWaitPolicy`, and never makes a target permanently ineligible —
1167
+ * see `OcxComboConfig.cooldownWaitPolicy` (#5691).
1168
+ */
1169
+ lastResort?: boolean;
1142
1170
  }
1143
1171
 
1144
1172
  export interface OcxComboConfig {
@@ -1155,6 +1183,18 @@ export interface OcxComboConfig {
1155
1183
  cooldownMs?: number;
1156
1184
  /** Maximum wait for an eligible target cooldown to expire before failing closed. Default 0; range 0..600000, per selection attempt. */
1157
1185
  waitForCooldownMs?: number;
1186
+ /**
1187
+ * `before-last-resort` defers targets marked `lastResort` while a normal
1188
+ * target is merely cooling and that cooldown can be waited out inside
1189
+ * `waitForCooldownMs`. Omitted keeps today's behavior, where a brief cooldown
1190
+ * on a preferred target routes straight to the emergency target (#5691).
1191
+ *
1192
+ * It only ever defers. When no normal target can be reached — all cooling
1193
+ * past the budget, excluded, or ruled out by the caller — the last-resort
1194
+ * target is dispatched, because a policy that could withhold it would turn a
1195
+ * fallback into an outage.
1196
+ */
1197
+ cooldownWaitPolicy?: OcxComboCooldownWaitPolicy;
1158
1198
  /** Used as a fallback when the client omits reasoning.effort, or as an override in `force` mode. null/omitted leaves the target default unchanged. */
1159
1199
  defaultEffort?: OcxComboDefaultEffort | null;
1160
1200
  /** `force` makes the combo default override a valid client effort. Omitted / `fallback` preserves client precedence. */
@@ -377,6 +377,13 @@ export interface OcxProviderConfig {
377
377
  * An explicit config value always wins over the registry default.
378
378
  */
379
379
  supportsServiceTier?: boolean;
380
+ /**
381
+ * Operator switch for the provider's Fast lane. `false` turns Fast off (no Fast toggle, no
382
+ * `--fast` row, no fast wire field) and overrides `supportsServiceTier`; `true` enables a lane the
383
+ * registry marks opt-in (Anthropic fast mode, which draws usage credits at 2x price). Absent keeps
384
+ * the registry default: off for opt-in entries, unchanged elsewhere.
385
+ */
386
+ fastEnabled?: boolean;
380
387
  /** Exact upstream model ids that override the provider-level service-tier capability. */
381
388
  modelSupportsServiceTier?: Record<string, boolean>;
382
389
  /**
@@ -461,11 +468,14 @@ export interface OcxProviderConfig {
461
468
  * streaming POST turns use the configured Responses path (default `/v1/responses`): forward
462
469
  * providers use `{baseUrl}/responses`, while key-auth providers use `responsesPath` or the
463
470
  * legacy `/v1/responses` fallback. HTTPS providers use wss and are re-encoded to SSE; HTTP
464
- * providers continue using SSE, and `openai-chat` requests stay on HTTP. This mirrors the
465
- * canonical ChatGPT backend optimization for any OpenAI-compatible gateway that speaks the
466
- * Responses WebSocket protocol (for example an aggregator like sub2api whose WS ingress is
467
- * measurably faster than its SSE queue). Default false. Canonical ChatGPT backend WS selection
468
- * is independent of this flag.
471
+ * providers continue using SSE, and `openai-chat` requests stay on HTTP. On a custom provider this
472
+ * opt-in is honored only for the first-party `https://api.openai.com/v1` upstream; every other
473
+ * endpoint stays on bounded HTTP/SSE. On the canonical ChatGPT `openai` provider the field
474
+ * selects the transport instead of opting in: omitted keeps the upstream WebSocket for eligible
475
+ * turns, an explicit `false` sends streaming turns over HTTP/SSE, and provider management rejects `true`. Either
476
+ * way it is independent of the client-facing `websockets` setting and changes neither the
477
+ * endpoint nor the credential; with `false`, native mid-turn steering and injection are
478
+ * unavailable.
469
479
  */
470
480
  upstreamWebsocket?: boolean;
471
481
  /**
@@ -810,6 +820,13 @@ export interface OcxProviderConfig {
810
820
  noTemperatureModels?: string[];
811
821
  /** Model ids that reject caller-specified top_p. */
812
822
  noTopPModels?: string[];
823
+ /**
824
+ * Model ids that reject caller-specified stop sequences. The openai-chat adapter
825
+ * drops `stop` for these (xAI grok-4.6 answers 400 invalid-argument
826
+ * "Model grok-4.6 does not support parameter stop.", which makes Claude Code's
827
+ * auto-mode safety classifier report the model as temporarily unavailable).
828
+ */
829
+ noStopModels?: string[];
813
830
  /** Model ids that reject caller-specified presence/frequency penalty values. */
814
831
  noPenaltyModels?: string[];
815
832
  /**
@@ -67,9 +67,11 @@ export function dottedToolName(namespace: string | undefined, name: string): str
67
67
  * Codex's code-mode shell tool is declared as `exec` (a freeform custom tool whose own
68
68
  * description mentions the nested `await tools.exec_command(...)` helper). Some routed providers
69
69
  * echo that helper name as the tool-call name, emitting `exec_command`, `write_stdin`,
70
- * `apply_patch`, or `view_image` instead of the declared `exec`. Accept these nested helper names
71
- * only when the request catalog actually declares `exec` and does not itself declare the emitted
72
- * name (an MCP server may legitimately advertise one under its own namespace).
70
+ * `apply_patch`, `view_image`, or one of the goal helpers (`create_goal`, `get_goal`,
71
+ * `update_goal`, #5495) instead of the declared `exec`. Accept these nested helper names only
72
+ * when the request catalog actually declares `exec` and does not itself declare the emitted name
73
+ * (an MCP server may legitimately advertise one under its own namespace). The list is closed: an
74
+ * unlisted name is never admitted through `exec`.
73
75
  */
74
76
  const LEGACY_SHELL_BRIDGE_TOOL_NAMES = ["exec_command", "shell_command"] as const;
75
77
  const CODE_MODE_HELPER_TOOL_NAMES = [
@@ -77,6 +79,9 @@ const CODE_MODE_HELPER_TOOL_NAMES = [
77
79
  "write_stdin",
78
80
  "apply_patch",
79
81
  "view_image",
82
+ "create_goal",
83
+ "get_goal",
84
+ "update_goal",
80
85
  ] as const;
81
86
 
82
87
  /**
@@ -105,7 +110,7 @@ export const CODE_MODE_HELPER_WIRE_NAMES: ReadonlySet<string> = new Set<string>(
105
110
  *
106
111
  * A bare alias is an ordinary compatibility affordance -- providers echo a namespaced tool
107
112
  * without its prefix, and restoring the identity needs the bare spelling registered. For these
108
- * six it is also an authorization decision, because a declared-name set is what
113
+ * names it is also an authorization decision, because a declared-name set is what
109
114
  * `normalizeDeclaredToolName` and `declaresCodeModeExec` read: bare `exec` turns nested-helper
110
115
  * normalization on for a catalog that never declared the shell, bare `exec_command` or
111
116
  * `shell_command` turns it off for one that did, and the rest are accepted as declared calls the
@@ -131,7 +136,8 @@ export const NAMESPACED_BARE_ALIAS_EXCLUDED_NAMES: ReadonlySet<string> = new Set
131
136
  * is declared and neither `default.<name>` nor `default__<name>` was explicitly declared (#4176).
132
137
  * The same wrapper may surround an already-flattened namespace identity; accept that exact
133
138
  * declared suffix without treating its child name as a bare declaration.
134
- * Also normalizes legacy helper names (`exec_command`, `shell_command`, `apply_patch`, `view_image`) to
139
+ * Also normalizes nested helper names (`exec_command`, `shell_command`, `write_stdin`,
140
+ * `apply_patch`, `view_image`, `create_goal`, `get_goal`, `update_goal`) to
135
141
  * `exec` when code-mode `exec` is declared in the request catalog.
136
142
  *
137
143
  * @param name - The tool name emitted on the wire by the provider.
package/src/types.ts CHANGED
@@ -77,6 +77,7 @@ export type {
77
77
  OcxConfig,
78
78
  OcxAccountPoolRotationStrategy,
79
79
  OcxAccountPoolQuotaWindow,
80
+ OcxComboCooldownWaitPolicy,
80
81
  OcxComboStrategy,
81
82
  OcxComboDefaultEffort,
82
83
  OcxComboDefaultEffortMode,
@@ -0,0 +1,109 @@
1
+ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
2
+ import { unprivilegedOwnershipMutationEnvironment } from "../service/ownership-mutation-lease.mjs";
3
+ import { PKG, registrySpawnTarget, type Channel, type Installer } from "./index";
4
+ import type { PnpmGlobalOwner } from "./pnpm-global-install.mjs";
5
+
6
+ export const REGISTRY_DEADLINE_MS = 12_000;
7
+ export const REGISTRY_OUTPUT_LIMIT = 4_096;
8
+
9
+ export interface PnpmOwnerDeps {
10
+ workerUrl?: URL;
11
+ deadlineMs?: number;
12
+ invoked?: string;
13
+ }
14
+
15
+ export async function pnpmOwner(deps: PnpmOwnerDeps = {}): Promise<PnpmGlobalOwner | null> {
16
+ return new Promise(resolve => {
17
+ let worker: Worker;
18
+ try {
19
+ worker = new Worker((deps.workerUrl ?? new URL("./pnpm-owner-worker.ts", import.meta.url)).href);
20
+ } catch {
21
+ resolve(null);
22
+ return;
23
+ }
24
+ let done = false;
25
+ const finish = (owner: PnpmGlobalOwner | null) => {
26
+ if (done) return;
27
+ done = true;
28
+ clearTimeout(timer);
29
+ void worker.terminate();
30
+ resolve(owner);
31
+ };
32
+ const timer = setTimeout(() => finish(null), deps.deadlineMs ?? REGISTRY_DEADLINE_MS);
33
+ worker.onmessage = event => finish(event.data as PnpmGlobalOwner | null);
34
+ worker.onerror = () => finish(null);
35
+ try { worker.postMessage(deps.invoked ?? process.argv[1] ?? ""); }
36
+ catch { finish(null); }
37
+ });
38
+ }
39
+
40
+ export interface AsyncLookupDeps {
41
+ ownerFn: () => Promise<PnpmGlobalOwner | null>;
42
+ spawnFn: typeof spawn;
43
+ deadlineMs?: number;
44
+ }
45
+
46
+ const defaultDeps: AsyncLookupDeps = { ownerFn: pnpmOwner, spawnFn: spawn };
47
+
48
+ export async function latestVersionAsync(
49
+ channel: Channel,
50
+ installer: Installer,
51
+ deps: AsyncLookupDeps = defaultDeps,
52
+ ): Promise<string | null> {
53
+ if (installer === "source" || installer === "mise") return null;
54
+ let owner: PnpmGlobalOwner | null | undefined;
55
+ try { owner = installer === "pnpm" ? await deps.ownerFn() : undefined; }
56
+ catch { return null; }
57
+ if (installer === "pnpm" && !owner) return null;
58
+ const target = registrySpawnTarget(installer, ["view", `${PKG}@${channel}`, "version"], owner ?? undefined);
59
+ if (!target) return null;
60
+
61
+ return new Promise(resolve => {
62
+ let child: ChildProcessWithoutNullStreams;
63
+ try {
64
+ child = deps.spawnFn(target.bin, target.args, {
65
+ stdio: ["pipe", "pipe", "pipe"],
66
+ windowsHide: true,
67
+ env: unprivilegedOwnershipMutationEnvironment(target.env ?? process.env),
68
+ ...target.options,
69
+ }) as ChildProcessWithoutNullStreams;
70
+ } catch {
71
+ resolve(null);
72
+ return;
73
+ }
74
+ child.stdin.end();
75
+ let done = false;
76
+ let bytes = 0;
77
+ let stdout = "";
78
+ let failed = false;
79
+ const finish = (version: string | null) => {
80
+ if (done) return;
81
+ done = true;
82
+ clearTimeout(timer);
83
+ resolve(version);
84
+ };
85
+ const accept = (chunk: Buffer, capture: boolean) => {
86
+ bytes += chunk.length;
87
+ if (bytes > REGISTRY_OUTPUT_LIMIT) {
88
+ failed = true;
89
+ child.kill();
90
+ } else if (capture) {
91
+ stdout += chunk.toString("utf8");
92
+ }
93
+ };
94
+ child.stdout.on("data", (chunk: Buffer) => accept(chunk, true));
95
+ child.stderr.on("data", (chunk: Buffer) => accept(chunk, false));
96
+ child.once("error", () => finish(null));
97
+ child.once("close", code => {
98
+ const value = stdout.trim();
99
+ finish(!failed && code === 0 && (channel === "latest"
100
+ ? /^\d+\.\d+\.\d+$/.test(value)
101
+ : /^\d+\.\d+\.\d+(?:-preview\.\d+)?$/.test(value))
102
+ ? value : null);
103
+ });
104
+ const timer = setTimeout(() => {
105
+ child.kill();
106
+ finish(null);
107
+ }, deps.deadlineMs ?? REGISTRY_DEADLINE_MS);
108
+ });
109
+ }
@@ -1,20 +1,7 @@
1
- /**
2
- * Cached "is an update available?" answer for the GUI sidebar badge.
3
- *
4
- * `/api/update/check` spawns the installing manager's `view` command on every call
5
- * (~1s, network-bound), so a
6
- * sidebar that polls it would spawn a process per tick on every page of the GUI.
7
- * The badge instead READS the 20h version cache the CLI update prompt already
8
- * maintains (`~/.opencodex/version.json`).
9
- *
10
- * This is deliberately read-only: it must never trigger a registry refresh. The GUI
11
- * polls it, so a refresh-on-read would let repeated polls launch repeated manager `view`
12
- * helpers with no coalescing. Cache warming stays with `ocx start`
13
- * (`triggerBackgroundRefreshIfStale` in `src/update/notify.ts`) and with the explicit
14
- * `/api/update/check` the user reaches by clicking the sidebar update button.
15
- */
1
+ /** The badge only reads package cache. Server and explicit checks produce it;
2
+ * missing, wrong-channel, and 40-hour-old cache answers are unknown. */
16
3
  import { currentVersion, defaultUpdateTag, detectInstall, type Channel } from "./index";
17
- import { isNewer, isSourceBuildVersion, readVersionCache } from "./notify";
4
+ import { CACHE_MAX_AGE_MS, isNewer, isSourceBuildVersion, readVersionCache } from "./notify";
18
5
 
19
6
  export interface UpdateBadge {
20
7
  /** True only when a newer version exists on the current channel. */
@@ -22,6 +9,7 @@ export interface UpdateBadge {
22
9
  currentVersion: string;
23
10
  latestVersion: string | null;
24
11
  channel: Channel;
12
+ installer: ReturnType<typeof detectInstall> | "desktop";
25
13
  /** False for source checkouts, where the GUI cannot offer a one-click update. */
26
14
  canUpdate: boolean;
27
15
  /** True when no cached registry answer exists yet, so "no update" is unproven. */
@@ -32,12 +20,14 @@ export interface UpdateBadgeDeps {
32
20
  currentVersion: () => string;
33
21
  detectInstall: () => ReturnType<typeof detectInstall>;
34
22
  readCache: (channel: Channel) => ReturnType<typeof readVersionCache>;
23
+ now?: () => number;
35
24
  }
36
25
 
37
26
  const defaultDeps: UpdateBadgeDeps = {
38
27
  currentVersion,
39
28
  detectInstall,
40
29
  readCache: readVersionCache,
30
+ now: Date.now,
41
31
  };
42
32
 
43
33
  /**
@@ -53,7 +43,8 @@ export function readUpdateBadge(deps: UpdateBadgeDeps = defaultDeps): UpdateBadg
53
43
  currentVersion: current,
54
44
  latestVersion: null,
55
45
  channel,
56
- canUpdate: installer !== "source",
46
+ installer,
47
+ canUpdate: installer !== "source" && installer !== "mise",
57
48
  unknown: true,
58
49
  };
59
50
  // A source checkout has nothing to compare against, so "unknown" is not useful there.
@@ -63,6 +54,9 @@ export function readUpdateBadge(deps: UpdateBadgeDeps = defaultDeps): UpdateBadg
63
54
 
64
55
  const cache = deps.readCache(channel);
65
56
  if (!cache) return base;
57
+ const checked = Date.parse(cache.last_checked_at);
58
+ const now = deps.now?.() ?? Date.now();
59
+ if (!Number.isFinite(checked) || checked > now || now - checked >= CACHE_MAX_AGE_MS) return base;
66
60
 
67
61
  return {
68
62
  ...base,
@@ -0,0 +1,9 @@
1
+ import type { Channel, Installer, InstallOwnership } from "./index";
2
+
3
+ export interface UpdateCheckDeps {
4
+ currentVersion: () => string;
5
+ detectInstall: () => Installer;
6
+ detectInstallOwnership?: () => InstallOwnership;
7
+ latestVersion: (tag: Channel) => string | null;
8
+ miseUpdateCommand?: (ownership: InstallOwnership) => string | null;
9
+ }
@@ -0,0 +1,102 @@
1
+ import type { UpdateBadge } from "./badge";
2
+ import { defaultUpdateTag } from "./index";
3
+
4
+ export type DesktopPhase =
5
+ | "idle" | "checking" | "available" | "current"
6
+ | "error" | "installing" | "install-failed";
7
+
8
+ export interface DesktopSnapshot {
9
+ sessionId: string;
10
+ currentVersion: string;
11
+ latestVersion: string | null;
12
+ available: boolean;
13
+ checkedAtMs: number | null;
14
+ phase: DesktopPhase;
15
+ }
16
+
17
+ const SESSION = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
18
+ const VERSION = /^[0-9A-Za-z][0-9A-Za-z.+_-]{0,63}$/;
19
+ const PHASES = new Set<DesktopPhase>([
20
+ "idle", "checking", "available", "current", "error", "installing", "install-failed",
21
+ ]);
22
+ export const DESKTOP_SNAPSHOT_TTL_MS = 180_000;
23
+ export const DESKTOP_SNAPSHOT_MAX_SESSIONS = 32;
24
+ const MIN_DESKTOP_CHECKED_AT_MS = 946_684_800_000; // 2000-01-01 UTC
25
+ const MAX_DESKTOP_CLOCK_SKEW_MS = 60_000;
26
+
27
+ export function validDesktopSession(value: unknown): value is string {
28
+ return typeof value === "string" && SESSION.test(value);
29
+ }
30
+
31
+ export function parseDesktopSnapshot(value: unknown, nowMs: number): DesktopSnapshot | null {
32
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
33
+ const body = value as Record<string, unknown>;
34
+ const keys = Object.keys(body).sort();
35
+ if (keys.join(",") !== "available,checkedAtMs,currentVersion,latestVersion,phase,sessionId") return null;
36
+ if (!validDesktopSession(body.sessionId)
37
+ || typeof body.currentVersion !== "string" || !VERSION.test(body.currentVersion)
38
+ || (body.latestVersion !== null && (typeof body.latestVersion !== "string" || !VERSION.test(body.latestVersion)))
39
+ || typeof body.available !== "boolean"
40
+ || typeof body.phase !== "string" || !PHASES.has(body.phase as DesktopPhase)) return null;
41
+ const checked = body.checkedAtMs;
42
+ if (checked !== null && (typeof checked !== "number" || !Number.isSafeInteger(checked) || checked < 0
43
+ || checked < MIN_DESKTOP_CHECKED_AT_MS
44
+ || checked > nowMs + MAX_DESKTOP_CLOCK_SKEW_MS)) return null;
45
+ if (body.available !== (body.latestVersion !== null)) return null;
46
+ if (body.available && checked === null) return null;
47
+ if (body.phase === "available" && !body.available) return null;
48
+ if (body.phase === "current" && (body.available || checked === null)) return null;
49
+ if (body.phase === "idle" && (body.available || checked !== null)) return null;
50
+ if ((body.phase === "installing" || body.phase === "install-failed") && !body.available) return null;
51
+ return body as unknown as DesktopSnapshot;
52
+ }
53
+
54
+ export class DesktopBadgeStore {
55
+ private readonly entries = new Map<string, { snapshot: DesktopSnapshot; receivedAtMs: number }>();
56
+ constructor(
57
+ private readonly wallNowMs: () => number = Date.now,
58
+ private readonly elapsedNowMs: () => number = () => performance.now(),
59
+ ) {}
60
+
61
+ put(value: unknown): boolean {
62
+ const snapshot = parseDesktopSnapshot(value, this.wallNowMs());
63
+ if (!snapshot) return false;
64
+ const now = this.elapsedNowMs();
65
+ this.prune(now);
66
+ this.entries.delete(snapshot.sessionId);
67
+ while (this.entries.size >= DESKTOP_SNAPSHOT_MAX_SESSIONS) {
68
+ this.entries.delete(this.entries.keys().next().value!);
69
+ }
70
+ this.entries.set(snapshot.sessionId, { snapshot, receivedAtMs: now });
71
+ return true;
72
+ }
73
+
74
+ read(sessionId: string | null): UpdateBadge {
75
+ const now = this.elapsedNowMs();
76
+ this.prune(now);
77
+ const snapshot = sessionId && validDesktopSession(sessionId)
78
+ ? this.entries.get(sessionId)?.snapshot : undefined;
79
+ if (!snapshot) return {
80
+ updateAvailable: false, currentVersion: "?", latestVersion: null,
81
+ channel: "latest", installer: "desktop", canUpdate: true, unknown: true,
82
+ };
83
+ return {
84
+ updateAvailable: snapshot.available,
85
+ currentVersion: snapshot.currentVersion,
86
+ latestVersion: snapshot.latestVersion,
87
+ channel: defaultUpdateTag(snapshot.currentVersion),
88
+ installer: "desktop",
89
+ canUpdate: true,
90
+ unknown: snapshot.phase === "idle"
91
+ || ((snapshot.phase === "checking" || snapshot.phase === "error") && !snapshot.available),
92
+ };
93
+ }
94
+
95
+ private prune(now: number): void {
96
+ for (const [key, entry] of this.entries) {
97
+ if (now - entry.receivedAtMs >= DESKTOP_SNAPSHOT_TTL_MS) this.entries.delete(key);
98
+ }
99
+ }
100
+ }
101
+
102
+ export const desktopBadgeStore = new DesktopBadgeStore();
@@ -15,7 +15,15 @@ import { unprivilegedOwnershipMutationEnvironment } from "../service/ownership-m
15
15
  import { withUpdateOwnershipLease, readUpdateRuntimeTarget } from "./ownership-transaction";
16
16
  import { npmInvocation } from "./npm-invocation.mjs";
17
17
  import { pnpmInvocation, pnpmInvocationForPath, resolvePnpmCommands } from "./pnpm-invocation.mjs";
18
- import { detectInstallFromPath } from "./install-detection.mjs";
18
+ import {
19
+ detectInstallFromPath,
20
+ detectInstallOwnershipFromPath,
21
+ } from "./install-detection.mjs";
22
+ import type {
23
+ DetectedInstall,
24
+ InstallOwnership,
25
+ MiseInstallOwner,
26
+ } from "./install-detection.d.mts";
19
27
  import {
20
28
  pnpmOwnerInvocation,
21
29
  readPnpmGlobalPackage,
@@ -50,20 +58,33 @@ export function historyRestoreIncomplete(configDir = getConfigDir()): boolean {
50
58
  export const PKG = "@bitkyc08/opencodex";
51
59
  const HERE = dirname(fileURLToPath(import.meta.url)); // .../opencodex/src/update
52
60
 
53
- export type Installer = "bun" | "npm" | "pnpm" | "source";
61
+ export type Installer = DetectedInstall;
54
62
  export type Channel = "latest" | "preview";
63
+ export type { InstallOwnership, MiseInstallOwner };
55
64
 
56
65
  /** Infer how opencodex is installed from the running module's path. */
57
66
  export function detectInstall(): Installer {
58
67
  return detectInstallFromPath(HERE, { exists: existsSync });
59
68
  }
60
69
 
70
+ /** Resolve installer ownership and verified mise update guidance for this package. */
71
+ export function detectInstallOwnership(): InstallOwnership {
72
+ return detectInstallOwnershipFromPath(HERE, { exists: existsSync });
73
+ }
74
+
75
+ export function miseUpdateCommand(
76
+ ownership: InstallOwnership = detectInstallOwnership(),
77
+ ): string | null {
78
+ return ownership.installer === "mise" && ownership.owner
79
+ ? `mise upgrade ${ownership.owner.tool}`
80
+ : null;
81
+ }
82
+
61
83
  function packageRoot(): string {
62
84
  return resolve(HERE, "..", "..");
63
85
  }
64
86
 
65
- function runningPnpmShimPath(): string | undefined {
66
- const invoked = process.argv[1];
87
+ function runningPnpmShimPath(invoked = process.argv[1]): string | undefined {
67
88
  if (!invoked) return undefined;
68
89
  const name = invoked.replaceAll("\\", "/").split("/").at(-1)?.toLowerCase();
69
90
  if (!new Set(["ocx", "opencodex", "ocx.cmd", "opencodex.cmd", "ocx.ps1", "opencodex.ps1"]).has(name ?? "")) {
@@ -90,12 +111,12 @@ function runPnpmCandidate(
90
111
  }
91
112
 
92
113
  /** Resolve the exact pnpm executable/group/bin that own this package. */
93
- export function resolveCurrentPnpmGlobalOwner(): PnpmGlobalOwnerResult {
114
+ export function resolveCurrentPnpmGlobalOwner(invoked = process.argv[1]): PnpmGlobalOwnerResult {
94
115
  return resolvePnpmGlobalOwner({
95
116
  packageName: PKG,
96
117
  packagePath: packageRoot(),
97
118
  commandPaths: resolvePnpmCommands(),
98
- runningShimPath: runningPnpmShimPath(),
119
+ runningShimPath: runningPnpmShimPath(invoked),
99
120
  runPnpm: runPnpmCandidate,
100
121
  });
101
122
  }
@@ -170,6 +191,8 @@ type SpawnTarget = {
170
191
  env?: Record<string, string | undefined>;
171
192
  };
172
193
 
194
+ export type RegistrySpawnTarget = SpawnTarget;
195
+
173
196
  function npmSpawnTarget(args: readonly string[]): SpawnTarget | null {
174
197
  const invocation = npmInvocation(args);
175
198
  if (!invocation) return null;
@@ -192,7 +215,7 @@ function pnpmSpawnTarget(args: readonly string[], owner?: PnpmGlobalOwner): Spaw
192
215
  return { bin: invocation.file, args: invocation.args, options: invocation.options };
193
216
  }
194
217
 
195
- function registrySpawnTarget(
218
+ export function registrySpawnTarget(
196
219
  installer: Installer,
197
220
  args: readonly string[],
198
221
  owner?: PnpmGlobalOwner,
@@ -269,6 +292,9 @@ export function latestVersion(
269
292
 
270
293
  /** The global-install command opencodex would run to update on this channel. */
271
294
  export function updateCommand(installer: Installer, tag: Channel, resolvedVersion?: string | null): { bin: string; args: string[] } {
295
+ if (installer === "mise") {
296
+ throw new Error("mise-owned installations must be upgraded through mise");
297
+ }
272
298
  // Immutable target: when the registry resolved a concrete version, install exactly
273
299
  // that version — the dist-tag can move between resolution and install (TOCTOU).
274
300
  const target = resolvedVersion || tag;
@@ -361,11 +387,25 @@ async function resolvedRuntimeOwnership(): Promise<RuntimeOwnershipObservation>
361
387
  * Bun binary.
362
388
  */
363
389
  export async function runUpdate(): Promise<void> {
364
- const installer = detectInstall();
390
+ const ownership = detectInstallOwnership();
391
+ const installer = ownership.installer;
365
392
  const current = currentVersion();
366
393
  const tag = updateTag(current);
367
394
  console.log(`opencodex v${current} (installed via ${installer}, tag ${tag})`);
368
395
 
396
+ if (installer === "mise") {
397
+ const command = miseUpdateCommand(ownership);
398
+ if (command) {
399
+ console.error(`OpenCodex is externally managed by mise. Update it with: ${command}`);
400
+ } else {
401
+ console.error(
402
+ "OpenCodex appears to be managed by mise, but its ownership metadata is unreadable or inconsistent. Repair the mise installation metadata before updating.",
403
+ );
404
+ }
405
+ process.exitCode = 1;
406
+ return;
407
+ }
408
+
369
409
  if (installer === "source") {
370
410
  console.log("Running from a source checkout — update with: git pull && bun install");
371
411
  return;