@bitkyc08/opencodex 2.63.0 → 2.64.0-preview.20260923

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 (262) hide show
  1. package/AGENTS_INSTALL.md +4 -3
  2. package/README.md +46 -33
  3. package/assets/download-linux.svg +10 -0
  4. package/assets/download-macos.svg +10 -0
  5. package/assets/download-windows.svg +10 -0
  6. package/bin/ocx.mjs +12 -4
  7. package/gui/dist/assets/App-D5eN1TID.js +50 -0
  8. package/gui/dist/assets/App-I5AnaSLh.css +1 -0
  9. package/gui/dist/assets/{Tray-CncKDBTp.js → Tray-Bh_ErQDh.js} +1 -1
  10. package/gui/dist/assets/index-BEq4OCOz.js +86 -0
  11. package/gui/dist/assets/index-DiBRuK-d.css +1 -0
  12. package/gui/dist/assets/usage-companion-chart-DBG_37kQ.js +1 -0
  13. package/gui/dist/index.html +2 -2
  14. package/native/remote-workspace-helper/src/protocol.rs +5 -0
  15. package/package.json +4 -1
  16. package/src/adapters/anthropic.ts +66 -7
  17. package/src/adapters/codebuddy/adapter.ts +112 -16
  18. package/src/adapters/codebuddy/mcp-server.ts +180 -0
  19. package/src/adapters/codebuddy/scaffold-guard.ts +25 -8
  20. package/src/adapters/codebuddy/tool-bridge.ts +597 -0
  21. package/src/adapters/coding-agent/protocol.ts +123 -10
  22. package/src/adapters/coding-agent/turn.ts +324 -2
  23. package/src/adapters/command-code-restored-schema.ts +112 -0
  24. package/src/adapters/command-code-tool-text.ts +589 -0
  25. package/src/adapters/command-code.ts +80 -6
  26. package/src/adapters/cursor/catalog.ts +12 -0
  27. package/src/adapters/cursor/current-request.ts +46 -0
  28. package/src/adapters/cursor/discovery.ts +1 -1
  29. package/src/adapters/cursor/effort-map.ts +4 -0
  30. package/src/adapters/cursor/native-exec.ts +15 -0
  31. package/src/adapters/cursor/protobuf-events.ts +40 -12
  32. package/src/adapters/cursor/protobuf-request.ts +83 -18
  33. package/src/adapters/cursor/request-builder.ts +5 -4
  34. package/src/adapters/cursor/tool-guidance.ts +1 -1
  35. package/src/adapters/devin/live-models.ts +7 -0
  36. package/src/adapters/inline-think-tags.ts +251 -0
  37. package/src/adapters/kiro/adapter.ts +1 -0
  38. package/src/adapters/kiro/stream.ts +5 -3
  39. package/src/adapters/kiro/usage.ts +4 -3
  40. package/src/adapters/mimo-free.ts +37 -18
  41. package/src/adapters/openai-chat/messages.ts +5 -5
  42. package/src/adapters/openai-chat/tool-schema.ts +124 -2
  43. package/src/adapters/openai-chat.ts +13 -3
  44. package/src/adapters/openai-responses/passthrough.ts +15 -2
  45. package/src/adapters/openai-responses/reasoning.ts +14 -1
  46. package/src/adapters/openai-responses/request-strips.ts +29 -3
  47. package/src/adapters/openai-responses/tool-output-recovery.ts +9 -3
  48. package/src/adapters/qoder/adapter.ts +15 -12
  49. package/src/adapters/qoder/scaffold-guard.ts +45 -13
  50. package/src/bridge/internal.ts +20 -0
  51. package/src/bridge/sse.ts +14 -5
  52. package/src/claude/agents-inject.ts +2 -1
  53. package/src/claude/desktop-applied-marker.ts +44 -0
  54. package/src/claude/desktop-profile.ts +22 -12
  55. package/src/claude/inbound.ts +8 -3
  56. package/src/cli/access.ts +22 -5
  57. package/src/cli/account-api.ts +6 -0
  58. package/src/cli/account-auth.ts +5 -2
  59. package/src/cli/account.ts +5 -6
  60. package/src/cli/aside-profiles.ts +61 -1
  61. package/src/cli/claude-desktop.ts +3 -2
  62. package/src/cli/claude.ts +3 -1
  63. package/src/cli/codex-cli-update.ts +2 -2
  64. package/src/cli/codex-shim-autorestore.ts +3 -0
  65. package/src/cli/dispatch.ts +12 -4
  66. package/src/cli/index.ts +98 -10
  67. package/src/cli/registry.ts +1 -1
  68. package/src/cli/resolve.ts +113 -1
  69. package/src/cli/root.ts +5 -0
  70. package/src/cli/stop-approval.ts +186 -0
  71. package/src/cli/stop-report.ts +1 -1
  72. package/src/cli/system-restart-client.ts +19 -4
  73. package/src/client/hub-client.ts +25 -18
  74. package/src/clients/config-export.ts +12 -4
  75. package/src/codex/account-auto-switch.ts +59 -0
  76. package/src/codex/account-lifecycle.ts +7 -0
  77. package/src/codex/account-priority.ts +10 -0
  78. package/src/codex/account-usability.ts +9 -0
  79. package/src/codex/auth-api/account-list.ts +5 -0
  80. package/src/codex/auth-api/login-flow.ts +15 -5
  81. package/src/codex/auth-api/login-state.ts +5 -16
  82. package/src/codex/auth-api/pool-mode-gate.ts +30 -7
  83. package/src/codex/auth-api/routes.ts +39 -3
  84. package/src/codex/auth-context.ts +145 -17
  85. package/src/codex/catalog/build-entries.ts +2 -1
  86. package/src/codex/catalog/effort.ts +8 -2
  87. package/src/codex/catalog/metadata.ts +33 -7
  88. package/src/codex/catalog/native-models.ts +102 -5
  89. package/src/codex/catalog/parsing.ts +2 -0
  90. package/src/codex/catalog/provider-models.ts +19 -6
  91. package/src/codex/cli-installation-targets.ts +44 -40
  92. package/src/codex/codex-write-lock.ts +32 -2
  93. package/src/codex/inject/multi-agent-v2.ts +90 -0
  94. package/src/codex/inject/plan.ts +365 -0
  95. package/src/codex/inject.ts +237 -362
  96. package/src/codex/log-guard/maintenance.ts +30 -13
  97. package/src/codex/project-config-warnings.ts +47 -7
  98. package/src/codex/prompt-layers/encoding.ts +41 -0
  99. package/src/codex/prompt-layers/toml-read.ts +1 -1
  100. package/src/codex/prompt-layers.ts +17 -13
  101. package/src/codex/prompt-text-probe.ts +20 -10
  102. package/src/codex/quota-observation-freshness.ts +34 -0
  103. package/src/codex/quota-rejection.ts +8 -4
  104. package/src/codex/quota.ts +4 -2
  105. package/src/codex/routing/selection.ts +32 -9
  106. package/src/codex/routing.ts +53 -11
  107. package/src/codex/subagent-model-fallback.ts +4 -3
  108. package/src/codex/sync.ts +11 -0
  109. package/src/codex/windows-installation-files.ts +33 -11
  110. package/src/codex/write-coordination.ts +4 -0
  111. package/src/combos/request.ts +6 -2
  112. package/src/companion/settings.ts +6 -1
  113. package/src/config/atomic-write.ts +12 -1
  114. package/src/config/derived-registries.ts +29 -0
  115. package/src/config/diagnostics.ts +17 -0
  116. package/src/config/live-reconcile.ts +11 -2
  117. package/src/config/load-degrade.ts +9 -2
  118. package/src/config/persist-unlocked.ts +4 -3
  119. package/src/config/persisted-mutation.ts +94 -0
  120. package/src/config/provider-validation.ts +1 -1
  121. package/src/config/proxy-env.ts +62 -12
  122. package/src/config/rebase-provenance.ts +80 -1
  123. package/src/config/schema/config-schema.ts +5 -0
  124. package/src/config/schema/leaf-validators.ts +50 -1
  125. package/src/config/subagent-models.ts +41 -15
  126. package/src/config.ts +18 -95
  127. package/src/generated/compatibility-version.json +330 -218
  128. package/src/generated/model-metadata.ts +8 -5
  129. package/src/github/star-state.ts +46 -6
  130. package/src/grok/inject.ts +74 -40
  131. package/src/grok/status.ts +11 -4
  132. package/src/integrations/cursor-effort-table.ts +50 -13
  133. package/src/integrations/raycast-detect.ts +19 -4
  134. package/src/integrations/serialize.ts +11 -1
  135. package/src/lib/bounded-body.ts +30 -0
  136. package/src/lib/crash-guard.ts +52 -4
  137. package/src/lib/local-aside-sync-contract.ts +41 -0
  138. package/src/lib/package-tree-integrity.ts +181 -9
  139. package/src/lib/proxy-env.ts +18 -1
  140. package/src/lib/request-failure-attribution.ts +1 -0
  141. package/src/lib/request-failure-model.ts +3 -0
  142. package/src/lib/service-secrets.ts +99 -2
  143. package/src/lib/socks5-fetch.ts +43 -14
  144. package/src/lib/token-estimate.ts +17 -2
  145. package/src/oauth/command-code.ts +3 -2
  146. package/src/oauth/generic-account-failover.ts +29 -1
  147. package/src/oauth/index.ts +24 -16
  148. package/src/oauth/login-flow-state.ts +5 -5
  149. package/src/oauth/meta-muse-device.ts +49 -7
  150. package/src/oauth/store.ts +74 -9
  151. package/src/providers/alibaba-region-backup.ts +16 -1
  152. package/src/providers/anthropic-fast.ts +89 -0
  153. package/src/providers/anthropic-reset-grant-ledger.ts +288 -0
  154. package/src/providers/anthropic-reset-grants.ts +329 -0
  155. package/src/providers/claude-cli-identity.ts +12 -0
  156. package/src/providers/command-code-efforts.ts +184 -102
  157. package/src/providers/derive.ts +11 -3
  158. package/src/providers/fastwire.ts +16 -3
  159. package/src/providers/label.ts +8 -3
  160. package/src/providers/model-rename-fields.ts +1 -0
  161. package/src/providers/openai-virtual-models.ts +1 -0
  162. package/src/providers/quota/vendor-probes-oauth.ts +2 -1
  163. package/src/providers/reasoning-metadata.ts +38 -15
  164. package/src/providers/registry/entries-core.ts +95 -7
  165. package/src/providers/registry/entries-extended.ts +21 -10
  166. package/src/providers/registry/model-ids.ts +1 -0
  167. package/src/providers/registry/model-seeds.ts +31 -3
  168. package/src/providers/registry/types.ts +3 -1
  169. package/src/providers/resolved-model-policy-merge.ts +38 -8
  170. package/src/providers/resolved-model-policy.ts +4 -2
  171. package/src/providers/service-tier.ts +3 -1
  172. package/src/reasoning-effort.ts +6 -5
  173. package/src/responses/code-mode-helper-compat.ts +14 -4
  174. package/src/responses/code-mode-shell-input.ts +54 -0
  175. package/src/responses/custom-tool-compat.ts +173 -5
  176. package/src/responses/parser.ts +38 -2
  177. package/src/responses/reasoning-replay-cache.ts +26 -0
  178. package/src/router.ts +11 -1
  179. package/src/routing/history/indexer.ts +7 -2
  180. package/src/routing/history/schema.ts +3 -1
  181. package/src/server/adapter-resolve.ts +2 -2
  182. package/src/server/auth-cors.ts +3 -0
  183. package/src/server/chat-completions.ts +14 -1
  184. package/src/server/chat-native.ts +17 -0
  185. package/src/server/claude-messages.ts +4 -3
  186. package/src/server/direct-local-http.ts +45 -19
  187. package/src/server/gui-session.ts +6 -15
  188. package/src/server/index/package-tree-guard.ts +53 -0
  189. package/src/server/index/serve-options.ts +17 -3
  190. package/src/server/index/startup-warnings.ts +17 -0
  191. package/src/server/index.ts +15 -17
  192. package/src/server/lifecycle.ts +8 -0
  193. package/src/server/live.ts +54 -4
  194. package/src/server/management/agent-settings-routes.ts +44 -8
  195. package/src/server/management/anthropic-reset-grant-routes.ts +252 -0
  196. package/src/server/management/codex-prompt-routes.ts +5 -1
  197. package/src/server/management/config-routes.ts +14 -4
  198. package/src/server/management/oauth-account-routes.ts +17 -1
  199. package/src/server/management/provider-overwrite-carry.ts +164 -0
  200. package/src/server/management/provider-routes.ts +36 -6
  201. package/src/server/management/remote-workspace-routes.ts +2 -2
  202. package/src/server/management/route-registry.ts +2 -0
  203. package/src/server/management/shadow-call-validation.ts +39 -0
  204. package/src/server/management/system-restart.ts +72 -6
  205. package/src/server/management-api.ts +16 -2
  206. package/src/server/management-auth.ts +52 -8
  207. package/src/server/proxy-liveness.ts +110 -4
  208. package/src/server/request-log.ts +26 -9
  209. package/src/server/request-metrics.ts +5 -0
  210. package/src/server/responses/adapter-continuation.ts +6 -3
  211. package/src/server/responses/adapter-dispatch.ts +65 -1
  212. package/src/server/responses/compact.ts +31 -1
  213. package/src/server/responses/compaction-routing.ts +28 -3
  214. package/src/server/responses/core-codex-account.ts +85 -26
  215. package/src/server/responses/core-combo-failure.ts +16 -15
  216. package/src/server/responses/core-normalize.ts +5 -1
  217. package/src/server/responses/core-opaque-recovery.ts +50 -2
  218. package/src/server/responses/core-options.ts +2 -0
  219. package/src/server/responses/core-replay.ts +7 -0
  220. package/src/server/responses/core.ts +1 -1
  221. package/src/server/responses/encrypted-payload.ts +23 -9
  222. package/src/server/responses/passthrough-delivery.ts +55 -35
  223. package/src/server/responses/passthrough-dispatch.ts +12 -13
  224. package/src/server/responses/policy-fallback.ts +12 -1
  225. package/src/server/responses/request-prepare.ts +46 -7
  226. package/src/server/responses/request-spend.ts +31 -15
  227. package/src/server/responses/run-turn-execution.ts +6 -5
  228. package/src/server/responses/shadow-target-availability.ts +61 -0
  229. package/src/server/responses-custom-tool-repair.ts +2 -0
  230. package/src/service/claim.ts +185 -0
  231. package/src/service/cli.ts +10 -1
  232. package/src/service/guarded-manager-target.ts +151 -0
  233. package/src/service/guards.ts +47 -48
  234. package/src/service/managing-cli.ts +170 -0
  235. package/src/service/orchestration.ts +3 -1
  236. package/src/service/state.ts +4 -0
  237. package/src/service/systemd.ts +16 -1
  238. package/src/service.ts +1 -1
  239. package/src/types/config.ts +8 -0
  240. package/src/types/provider.ts +16 -0
  241. package/src/types/request.ts +10 -0
  242. package/src/types/tools.ts +9 -1
  243. package/src/types/wire.ts +68 -4
  244. package/src/types.ts +1 -0
  245. package/src/update/job.ts +10 -16
  246. package/src/update/npm-invocation.mjs +17 -16
  247. package/src/update/transactional-install.d.mts +22 -1
  248. package/src/update/transactional-install.mjs +176 -19
  249. package/src/update/update-failure-guidance.d.mts +8 -0
  250. package/src/update/update-failure-guidance.mjs +47 -0
  251. package/src/usage/expected-prices.ts +54 -12
  252. package/src/usage/log.ts +117 -3
  253. package/src/usage/telemetry-contract.ts +1 -0
  254. package/src/usage/timeline.ts +34 -6
  255. package/src/usage/user-cost-overlay-reconciler.ts +3 -3
  256. package/src/vision/reasoning.ts +2 -4
  257. package/src/web-search/xai-executor.ts +14 -4
  258. package/gui/dist/assets/App-BqrsSrIR.js +0 -50
  259. package/gui/dist/assets/index-C6SJrh0N.js +0 -86
  260. package/gui/dist/assets/index-DdDunwDb.css +0 -1
  261. package/gui/dist/assets/usage-companion-chart-CzAAAB1o.js +0 -1
  262. package/src/adapters/kiro-thinking.ts +0 -112
@@ -36,6 +36,7 @@ export interface ClaudeAgentDef {
36
36
  const OWNED_PREFIX = "ocx-";
37
37
  /** Ownership proof (audit 071 #2): a file without this marker is NEVER touched. */
38
38
  const GENERATED_MARKER = "generated-by: opencodex";
39
+ const SAFE_AGENT_MODEL_ID = /^[a-z0-9][a-z0-9._:/@+\[\]~-]*$/i;
39
40
 
40
41
  function sanitizeName(value: string): string {
41
42
  const cleaned = value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
@@ -156,7 +157,7 @@ export function buildClaudeAgentDefs(
156
157
  const roster = rosterOverride
157
158
  ?? (config.subagentModels === undefined ? DEFAULT_SUBAGENT_MODELS : config.subagentModels);
158
159
  for (const entry of roster.slice(0, 5)) {
159
- if (typeof entry !== "string" || entry.trim() === "") continue;
160
+ if (typeof entry !== "string" || !SAFE_AGENT_MODEL_ID.test(entry.trim())) continue;
160
161
  const { alias, id, provider } = entryParts(entry.trim(), config);
161
162
  push(sanitizeName(id), alias, `Delegate work to ${id} (${provider}) via opencodex routing. General-purpose worker/explorer on that model. ${NO_MODEL_ARG}`);
162
163
  }
@@ -0,0 +1,44 @@
1
+ import { mutatePersistedConfig, type PersistedConfigMutationOutcome } from "../config";
2
+ import { emptyDesktopProfile, sameProfileContent, type DesktopProfile } from "./desktop-profile";
3
+
4
+ export interface DesktopAppliedMarkerBaseline {
5
+ profile: DesktopProfile | undefined;
6
+ appliedFingerprint: string | undefined;
7
+ appliedAt: string | undefined;
8
+ }
9
+
10
+ export function captureDesktopAppliedMarker(
11
+ profile: DesktopProfile | undefined,
12
+ ): DesktopAppliedMarkerBaseline {
13
+ const snapshot = profile == null ? undefined : structuredClone(profile);
14
+ return {
15
+ profile: snapshot,
16
+ appliedFingerprint: snapshot?.appliedFingerprint,
17
+ appliedAt: snapshot?.appliedAt,
18
+ };
19
+ }
20
+
21
+ export function commitDesktopAppliedMarker(
22
+ baseline: DesktopAppliedMarkerBaseline,
23
+ fingerprint: string,
24
+ ): PersistedConfigMutationOutcome<boolean> {
25
+ const appliedAt = new Date().toISOString();
26
+ return mutatePersistedConfig<boolean>(persisted => {
27
+ const profile = persisted.claudeCode?.desktopProfile;
28
+ if ((profile == null) !== (baseline.profile === undefined)
29
+ || (profile && baseline.profile && !sameProfileContent(profile, baseline.profile))
30
+ || profile?.appliedFingerprint !== baseline.appliedFingerprint
31
+ || profile?.appliedAt !== baseline.appliedAt) {
32
+ return { changed: false, value: false };
33
+ }
34
+ persisted.claudeCode = {
35
+ ...(persisted.claudeCode ?? {}),
36
+ desktopProfile: {
37
+ ...(profile ?? emptyDesktopProfile()),
38
+ appliedFingerprint: fingerprint,
39
+ appliedAt,
40
+ },
41
+ };
42
+ return { changed: true, value: true };
43
+ });
44
+ }
@@ -77,15 +77,6 @@ function assertExactKeys(value: Record<string, unknown>, keys: readonly string[]
77
77
  }
78
78
  }
79
79
 
80
- /**
81
- * Applied-state markers survive every profile rebuild.
82
- *
83
- * `parseDesktopProfile`, `reconcileDesktopProfile` and `moveDesktopRoute` each construct a
84
- * fresh `{ version, assignments, defaults }`, and the management routes persist whatever they
85
- * return. Without this carry-through, saving an assignment — or merely dragging a model to
86
- * another family — would erase the fingerprint the apply route wrote, and the GUI would report
87
- * "not applied" for a config that is applied on disk.
88
- */
89
80
  function appliedMarkers(source: { appliedFingerprint?: unknown; appliedAt?: unknown }): {
90
81
  appliedFingerprint?: string;
91
82
  appliedAt?: string;
@@ -96,6 +87,19 @@ function appliedMarkers(source: { appliedFingerprint?: unknown; appliedAt?: unkn
96
87
  };
97
88
  }
98
89
 
90
+ export function sameProfileContent(left: DesktopProfile, right: DesktopProfile): boolean {
91
+ return DESKTOP_FAMILIES.every(family => left.defaults[family] === right.defaults[family])
92
+ && JSON.stringify(Object.entries(left.assignments).sort(([a], [b]) => a.localeCompare(b)))
93
+ === JSON.stringify(Object.entries(right.assignments).sort(([a], [b]) => a.localeCompare(b)));
94
+ }
95
+
96
+ /** Retain applied-state bookkeeping only when the desired Desktop config is unchanged. */
97
+ export function preserveDesktopAppliedState(source: DesktopProfile, rebuilt: DesktopProfile): DesktopProfile {
98
+ return sameProfileContent(source, rebuilt)
99
+ ? { ...rebuilt, ...appliedMarkers(source) }
100
+ : rebuilt;
101
+ }
102
+
99
103
  function isFamily(value: unknown): value is DesktopFamily {
100
104
  return typeof value === "string" && (DESKTOP_FAMILIES as readonly string[]).includes(value);
101
105
  }
@@ -244,7 +248,8 @@ export function reconcileDesktopProfile(
244
248
  const current = defaults[family];
245
249
  defaults[family] = current && assignments[current]?.family === family ? current : (members[0] ?? null);
246
250
  }
247
- return parseDesktopProfile({ version: 1, assignments, defaults, ...appliedMarkers(profile) });
251
+ const rebuilt = parseDesktopProfile({ version: 1, assignments, defaults });
252
+ return preserveDesktopAppliedState(profile, rebuilt);
248
253
  }
249
254
 
250
255
  export function moveDesktopRoute(
@@ -268,7 +273,7 @@ export function moveDesktopRoute(
268
273
  const destinationMembers = Object.keys(assignments).filter(key => assignments[key]!.family === family).sort();
269
274
  if (makeDefault || !defaults[family] || assignments[defaults[family]!]?.family !== family) defaults[family] = route;
270
275
  if (!defaults[family] && destinationMembers.length > 0) defaults[family] = destinationMembers[0]!;
271
- return parseDesktopProfile({ version: 1, assignments, defaults, ...appliedMarkers(parsed) });
276
+ return parseDesktopProfile({ version: 1, assignments, defaults });
272
277
  }
273
278
 
274
279
  export function setDesktopFamilyDefault(
@@ -280,7 +285,12 @@ export function setDesktopFamilyDefault(
280
285
  const members = Object.keys(parsed.assignments).filter(key => parsed.assignments[key]!.family === family);
281
286
  if (route === null && members.length > 0) throw new DesktopProfileError("cannot clear a non-empty family default", `profile.defaults.${family}`);
282
287
  if (route !== null && parsed.assignments[route]?.family !== family) throw new DesktopProfileError("route is not a member of this family", `profile.defaults.${family}`);
283
- return parseDesktopProfile({ ...parsed, defaults: { ...parsed.defaults, [family]: route } });
288
+ const rebuilt = parseDesktopProfile({
289
+ version: 1,
290
+ assignments: parsed.assignments,
291
+ defaults: { ...parsed.defaults, [family]: route },
292
+ });
293
+ return preserveDesktopAppliedState(parsed, rebuilt);
284
294
  }
285
295
 
286
296
  export function renderDesktopProfile(
@@ -118,6 +118,7 @@ export function effectiveBlockedSkillNames(cc?: Pick<OcxClaudeCodeConfig, "block
118
118
  /** Injected-skill payloads below this size are never stubbed (not worth it). */
119
119
  const SKILL_ELISION_MIN_CHARS = 10_000;
120
120
  const SKILL_TEXT_MARKER = "Base directory for this skill: ";
121
+ const SKILL_TEXT_PATH_MAX_CHARS = 4_096;
121
122
 
122
123
  interface SkillElisionContext {
123
124
  /** Skill-tool call ids whose input names a blocked skill (result-body carrier). */
@@ -138,11 +139,15 @@ const NO_ELISION: SkillElisionContext = { callIds: new Set(), names: [] };
138
139
  function maybeElideSkillText(text: string, names: readonly string[]): string {
139
140
  if (names.length === 0 || text.length < SKILL_ELISION_MIN_CHARS) return text;
140
141
  if (!text.startsWith(SKILL_TEXT_MARKER)) return text;
141
- const firstLineEnd = text.indexOf("\n");
142
- const dir = text.slice(SKILL_TEXT_MARKER.length, firstLineEnd === -1 ? text.length : firstLineEnd).trim();
142
+ const pathStart = SKILL_TEXT_MARKER.length;
143
+ const pathPrefix = text.slice(pathStart, pathStart + SKILL_TEXT_PATH_MAX_CHARS + 1);
144
+ const firstLineEnd = pathPrefix.indexOf("\n");
145
+ if (firstLineEnd === -1 && pathPrefix.length > SKILL_TEXT_PATH_MAX_CHARS) return text;
146
+ const dir = pathPrefix.slice(0, firstLineEnd === -1 ? pathPrefix.length : firstLineEnd).trim();
143
147
  // Windows clients send `C:\Users\...\claude-api`; normalize separators before
144
148
  // basenaming (repo precedent: src/codex/inject.ts isOpencodexCatalogPath).
145
- const base = dir.replace(/\\/g, "/").split("/").filter(Boolean).pop()?.toLowerCase() ?? "";
149
+ const normalizedDir = dir.replace(/\\/g, "/").replace(/\/+$/, "");
150
+ const base = normalizedDir.slice(normalizedDir.lastIndexOf("/") + 1).toLowerCase();
146
151
  if (!names.includes(base)) return text;
147
152
  return `[opencodex] '${base}' skill document bundle (${text.length} chars) elided for routed models `
148
153
  + "(claudeCode.blockedSkills). The skill is loaded; answer from general knowledge instead of citing the bundle.";
package/src/cli/access.ts CHANGED
@@ -22,6 +22,19 @@ const USAGE = `Usage:
22
22
  ocx access models [--json]
23
23
  ocx access test <model> [--protocol <chat|responses|messages>] [--json]`;
24
24
 
25
+ const UTC_ISO_INSTANT_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/;
26
+
27
+ /**
28
+ * The server emits `attributionSince` with `toISOString()`. `Date.parse` alone also accepts
29
+ * strings such as "0", so require the ISO-8601 UTC shape and an instant that round-trips to the
30
+ * same second, which also rejects impossible dates the parser would roll over.
31
+ */
32
+ function isUtcIsoInstant(value: unknown): value is string {
33
+ if (typeof value !== "string" || !UTC_ISO_INSTANT_RE.test(value)) return false;
34
+ const parsed = Date.parse(value);
35
+ return Number.isFinite(parsed) && new Date(parsed).toISOString().slice(0, 19) === value.slice(0, 19);
36
+ }
37
+
25
38
  /**
26
39
  * Render the key table with the usage fields the API already returns (#2705).
27
40
  *
@@ -38,6 +51,10 @@ const USAGE = `Usage:
38
51
  */
39
52
  function formatKeyRows(payload: Record<string, unknown>, keys: Array<Record<string, unknown>>): string[] {
40
53
  const cells: string[][] = [["ID", "NAME", "PREFIX", "REQ 7D", "TOTAL", "LAST USED"]];
54
+ // A string that is not an ISO-8601 UTC instant is not attribution data: treat it like an
55
+ // absent field so malformed payloads still render "unavailable" instead of usage values.
56
+ const attributionSince = isUtcIsoInstant(payload.attributionSince) ? payload.attributionSince : undefined;
57
+ const usageAvailable = attributionSince !== undefined;
41
58
  for (const entry of keys) {
42
59
  const usage = (entry.usage ?? {}) as Record<string, unknown>;
43
60
  const ambiguous = usage.ambiguous === true;
@@ -47,16 +64,16 @@ function formatKeyRows(payload: Record<string, unknown>, keys: Array<Record<stri
47
64
  String(entry.name ?? ""),
48
65
  String(entry.prefix ?? ""),
49
66
  // One marker spanning both numeric columns: the union guarantees neither exists.
50
- ambiguous ? "ambiguous" : num(usage.requests7d),
51
- ambiguous ? "" : num(usage.totalRequests),
52
- ambiguous ? "" : (typeof usage.lastUsedAt === "string" ? usage.lastUsedAt : "never"),
67
+ !usageAvailable ? "unavailable" : ambiguous ? "ambiguous" : num(usage.requests7d),
68
+ !usageAvailable || ambiguous ? "" : num(usage.totalRequests),
69
+ !usageAvailable || ambiguous ? "" : (typeof usage.lastUsedAt === "string" ? usage.lastUsedAt : "never"),
53
70
  ]);
54
71
  }
55
72
  const widths = cells[0]!.map((_, column) => Math.max(...cells.map(row => (row[column] ?? "").length)));
56
73
  const lines = cells.map(row => row.map((cell, i) => (cell ?? "").padEnd(widths[i]!)).join(" ").trimEnd());
57
74
  const footer: string[] = [];
58
- if (typeof payload.attributionSince === "string") {
59
- footer.push(`attribution since ${payload.attributionSince}`);
75
+ if (attributionSince !== undefined) {
76
+ footer.push(`attribution since ${attributionSince}`);
60
77
  }
61
78
  if (payload.historyTruncated === true) {
62
79
  footer.push("older history truncated");
@@ -33,6 +33,8 @@ export interface AccountRow {
33
33
  validationPending?: boolean;
34
34
  /** Codex pool selection order, higher used earlier. Absent where ordering does not apply. */
35
35
  priority?: number;
36
+ /** Null means the account inherits the global usage-switch threshold. */
37
+ autoSwitchThresholdOverride?: number | null;
36
38
  quota?: CodexQuotaDto | null;
37
39
  quotaRefresh?: CodexQuotaRefreshOutcome;
38
40
  quotaUnavailable?: boolean;
@@ -252,6 +254,7 @@ interface CodexAccountDto {
252
254
  selectionExcludedPlan?: string;
253
255
  health?: { reason?: string };
254
256
  priority?: number;
257
+ autoSwitchThresholdOverride?: number | null;
255
258
  quota?: CodexQuotaDto | null;
256
259
  quotaRefresh?: unknown;
257
260
  paused?: boolean;
@@ -322,6 +325,9 @@ export async function fetchCodexRows(
322
325
  } : {}),
323
326
  ...(a.health?.reason === "validation_pending" ? { validationPending: true } : {}),
324
327
  priority: typeof a.priority === "number" ? a.priority : 0,
328
+ autoSwitchThresholdOverride: typeof a.autoSwitchThresholdOverride === "number"
329
+ ? a.autoSwitchThresholdOverride
330
+ : null,
325
331
  paused: a.paused === true,
326
332
  ...(includeQuota ? {
327
333
  quota: projectQuota(a.quota),
@@ -36,7 +36,7 @@ function writeStdoutFully(text: string): void {
36
36
  const USAGE = `Usage:
37
37
  ocx account login <provider> [--id <account-id>] [--reauth] [--device] [--code -] [--no-wait] [--json]
38
38
  ocx account code <provider> [--flow <flow-id>] [--json] (reads the code from stdin)
39
- ocx account cancel <provider> [--flow <flow-id>] [--json]
39
+ ocx account cancel <provider> [--flow <flow-id>] [--json] (--flow required for codex)
40
40
  ocx account reset-credits <account-id|main> [--consume --yes [--operation-id <uuid>]] [--json]
41
41
  ocx account grok-reset-coupons [<account-id>] [--consume --yes [--token-id <token-id>] [--operation-id <uuid>]] [--json]
42
42
 
@@ -281,10 +281,13 @@ async function cancel(argv: string[], deps: RuntimeApiDeps): Promise<void> {
281
281
  const args = [...argv];
282
282
  const provider = args.shift()?.trim().toLowerCase();
283
283
  const wantsJson = takeFlag(args, "--json");
284
- const flowId = takeOption(args, "--flow");
284
+ const flowId = takeOption(args, "--flow")?.trim();
285
285
  if (!provider) throw new CliUsageError("provider is required", USAGE);
286
286
  rejectArgs(args, USAGE);
287
287
  const codex = CODEX_NAMES.has(provider);
288
+ if (codex && !flowId) {
289
+ throw new CliUsageError("Codex login cancel requires --flow <flow-id> (printed by 'ocx account login').", USAGE);
290
+ }
288
291
  const result = await runtimeRequest(codex ? "/api/codex-auth/login/cancel" : "/api/oauth/login/cancel", {
289
292
  method: "POST",
290
293
  body: JSON.stringify(codex ? { flowId } : { provider }),
@@ -60,7 +60,7 @@ const ACCOUNT_USAGE = `Usage:
60
60
  ocx account import-orca --source <orca-data-directory> --registry <orca-data.json> [--apply] [--json]
61
61
  ocx account login <provider> [--id <account-id>] [--reauth] [--code -] [--no-wait] [--json]
62
62
  ocx account code <provider> [--flow <flow-id>] [--json] (reads the code from stdin)
63
- ocx account cancel <provider> [--flow <flow-id>] [--json]
63
+ ocx account cancel <provider> [--flow <flow-id>] [--json] (--flow required for codex)
64
64
  ocx account reset-credits <account-id|main> [--consume --yes] [--json]
65
65
  ocx account grok-reset-coupons [<account-id>] [--consume --yes] [--token-id <token-id>] [--json]
66
66
  ocx account main <doctor|list|register|add|reauth|switch|recover> ...
@@ -340,10 +340,9 @@ async function cmdUse(rest: string[], deps: AccountDeps): Promise<number> {
340
340
  }
341
341
  if (c.type === "codex") {
342
342
  console.error("Takes effect immediately; running threads move on their next request, and in-flight requests keep the account they captured.");
343
- const active = await apiJson(deps, baseUrl, "GET", "/api/codex-auth/active");
344
- const threshold = active.status === 200 && typeof active.json.autoSwitchThreshold === "number"
345
- ? active.json.autoSwitchThreshold
346
- : undefined;
343
+ const state = await fetchRows(deps, baseUrl, name, "codex");
344
+ const selected = state.rows.find(row => row.id === activeId);
345
+ const threshold = selected?.autoSwitchThresholdOverride ?? state.autoSwitchThreshold;
347
346
  if (pinDrainReason !== undefined) {
348
347
  // "may override" is the right caveat for a pin that is currently fine and could be
349
348
  // overtaken later. It is the wrong sentence for one the next request will discard, and
@@ -352,7 +351,7 @@ async function cmdUse(rest: string[], deps: AccountDeps): Promise<number> {
352
351
  ? `is at or above the auto-switch threshold${threshold !== undefined ? ` (${threshold}%)` : ""}`
353
352
  : `cannot currently be selected (${pinDrainReason})`;
354
353
  console.error(`Note: ${displayId(activeId)} ${because}, so routing releases this pin on its next request.`);
355
- } else if (threshold !== undefined && threshold > 0) {
354
+ } else if (state.status === 200 && typeof threshold === "number" && threshold > 0) {
356
355
  console.error(`Note: auto-switch (threshold ${threshold}%) may override this pin.`);
357
356
  }
358
357
  }
@@ -1,10 +1,70 @@
1
1
  import type { OwnedIntegrationRefreshOutcome } from "../integrations/owned-refresh";
2
+ import { readRuntimePort } from "../config/process-state";
3
+ import { createLocalAttestationChallenge, LOCAL_ATTESTATION_CHALLENGE_HEADER, LOCAL_ATTESTATION_PROOF_HEADER, verifyLocalAttestationProof } from "../lib/local-management-attestation";
4
+ import { createLocalAsideSyncCapability, LOCAL_ASIDE_SYNC_CAPABILITY_HEADER, LOCAL_ASIDE_SYNC_CAPABILITY_TTL_MS, LOCAL_ASIDE_SYNC_CAPABILITY_VERSION, LOCAL_ASIDE_SYNC_EXPECTED_PID_HEADER, LOCAL_ASIDE_SYNC_EXPIRES_AT_HEADER, LOCAL_ASIDE_SYNC_METHOD, LOCAL_ASIDE_SYNC_NONCE_HEADER, LOCAL_ASIDE_SYNC_PATH } from "../lib/local-aside-sync-contract";
5
+ import { directLocalHttpFetch } from "../server/direct-local-http";
6
+ import { findLiveProxy, isOpencodexHealthz, probeHostname } from "../server/proxy-liveness";
2
7
  import { runtimeRequest, RuntimeApiError, type RuntimeApiDeps } from "./runtime-api";
3
8
 
4
9
  /** Aside policy and file writes share the running server's mutation owner. Never fall back locally. */
5
10
  export async function refreshAsideProfilesThroughServer(
6
- deps: RuntimeApiDeps = {},
11
+ // The optional transport seam observes the real direct-local exchange in listener tests.
12
+ deps: RuntimeApiDeps & {
13
+ directLocalFetch?: typeof directLocalHttpFetch;
14
+ exchangeDeadlineMs?: number;
15
+ scheduleExchangeDeadline?: (onTimeout: () => void, delayMs: number) => () => void;
16
+ } = {},
7
17
  ): Promise<OwnedIntegrationRefreshOutcome[]> {
18
+ // An explicit URL is an opt-in transport used by connected callers and tests.
19
+ if (!deps.baseUrl) {
20
+ const localFetch = deps.directLocalFetch ?? directLocalHttpFetch;
21
+ const controller = new AbortController();
22
+ const onTimeout = () => controller.abort(new DOMException("The Aside sync exchange timed out", "TimeoutError"));
23
+ const cancelDeadline = (deps.scheduleExchangeDeadline ?? ((callback, delayMs) => {
24
+ const timer = setTimeout(callback, delayMs);
25
+ return () => clearTimeout(timer);
26
+ }))(onTimeout, deps.exchangeDeadlineMs ?? LOCAL_ASIDE_SYNC_CAPABILITY_TTL_MS);
27
+ const deadline = controller.signal;
28
+ try {
29
+ const live = await (deps.findLiveProxy ?? findLiveProxy)();
30
+ if (!live) throw new RuntimeApiError("Proxy is not running. Start it with: ocx start", 503, null);
31
+ if (live.source !== "runtime" || live.pid === null) {
32
+ throw new RuntimeApiError("Aside profile synchronization requires an attested running proxy", 503, null);
33
+ }
34
+ const runtime = readRuntimePort(live.pid);
35
+ if (!runtime?.attestationSecret || runtime.pid !== live.pid || runtime.port !== live.port) {
36
+ throw new RuntimeApiError("Aside profile synchronization could not verify the running proxy", 503, null);
37
+ }
38
+ const nonce = createLocalAttestationChallenge();
39
+ const baseUrl = `http://${probeHostname(live.hostname)}:${live.port}`;
40
+ const proofResponse = await localFetch(`${baseUrl}/healthz`, { headers: { [LOCAL_ATTESTATION_CHALLENGE_HEADER]: nonce }, signal: deadline });
41
+ const health = await proofResponse.json().catch(() => null);
42
+ if (!proofResponse.ok || !isOpencodexHealthz(health) || health?.pid !== live.pid || health?.port !== live.port
43
+ || health?.asideSyncCapability !== LOCAL_ASIDE_SYNC_CAPABILITY_VERSION
44
+ || !verifyLocalAttestationProof(runtime.attestationSecret, nonce, live.pid, live.port, proofResponse.headers.get(LOCAL_ATTESTATION_PROOF_HEADER))) {
45
+ throw new RuntimeApiError("Aside profile synchronization could not attest the running proxy", 503, null);
46
+ }
47
+ const expiresAt = Date.now() + LOCAL_ASIDE_SYNC_CAPABILITY_TTL_MS;
48
+ const capability = createLocalAsideSyncCapability(runtime.attestationSecret, nonce, LOCAL_ASIDE_SYNC_METHOD, LOCAL_ASIDE_SYNC_PATH, live.pid, live.port, expiresAt);
49
+ if (!capability) throw new RuntimeApiError("Aside profile synchronization capability was unavailable", 503, null);
50
+ const response = await localFetch(`${baseUrl}${LOCAL_ASIDE_SYNC_PATH}`, {
51
+ method: LOCAL_ASIDE_SYNC_METHOD,
52
+ signal: deadline,
53
+ headers: {
54
+ [LOCAL_ASIDE_SYNC_EXPECTED_PID_HEADER]: String(live.pid),
55
+ [LOCAL_ASIDE_SYNC_NONCE_HEADER]: nonce,
56
+ [LOCAL_ASIDE_SYNC_EXPIRES_AT_HEADER]: String(expiresAt),
57
+ [LOCAL_ASIDE_SYNC_CAPABILITY_HEADER]: capability,
58
+ },
59
+ });
60
+ const body = await response.json().catch(() => null) as { results?: OwnedIntegrationRefreshOutcome[] } | null;
61
+ if (!response.ok) throw new RuntimeApiError("Aside profile synchronization was rejected", response.status, body);
62
+ if (!Array.isArray(body?.results)) throw new RuntimeApiError("The running proxy does not support Aside profile synchronization", 502, body);
63
+ return body.results;
64
+ } finally {
65
+ cancelDeadline();
66
+ }
67
+ }
8
68
  const result = await runtimeRequest<{ results?: OwnedIntegrationRefreshOutcome[] }>(
9
69
  "/api/client-integrations/aside/sync",
10
70
  { method: "POST", body: "{}" },
@@ -398,7 +398,8 @@ export async function handleClaudeDesktopCommand(argv: string[], deps: ApplyProf
398
398
  const applyInvocation = argv.length === 0 || command === "apply" || applyFlags.length > 0;
399
399
  if (applyInvocation) {
400
400
  const rest = argv.filter(arg => arg !== "apply");
401
- const parsedTarget = parseDesktopApplyArgs(rest, loadConfig());
401
+ const preApplyConfig = loadConfig();
402
+ const parsedTarget = parseDesktopApplyArgs(rest, preApplyConfig);
402
403
  if ("error" in parsedTarget) { console.error(parsedTarget.error); return 2; }
403
404
  const { target } = parsedTarget;
404
405
  try {
@@ -424,7 +425,7 @@ export async function handleClaudeDesktopCommand(argv: string[], deps: ApplyProf
424
425
  console.log(`Claude Desktop gateway 설정을 적용했습니다: ${result.path}`);
425
426
  for (const line of gatewayModeExplanation({
426
427
  requestedExplicitly: applyFlags.some(flag => flag !== "--first-party"),
427
- config: loadConfig(),
428
+ config: preApplyConfig,
428
429
  })) {
429
430
  console.log(line);
430
431
  }
package/src/cli/claude.ts CHANGED
@@ -572,7 +572,6 @@ export function claudeLaunchPreflight(
572
572
  */
573
573
  const NATIVE_STRIPPED_LEVERS = [
574
574
  "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY",
575
- "CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST",
576
575
  "CLAUDE_CODE_MAX_CONTEXT_TOKENS",
577
576
  "CLAUDE_CODE_AUTO_COMPACT_WINDOW",
578
577
  "CLAUDE_CODE_ALWAYS_ENABLE_EFFORT",
@@ -632,6 +631,9 @@ export function buildNativeClaudeEnv(
632
631
  }
633
632
 
634
633
  for (const name of NATIVE_STRIPPED_LEVERS) delete env[name];
634
+ // An explicit caller-owned guard must follow a caller-owned gateway and credential;
635
+ // otherwise settings.env can replace the destination while retaining the credential.
636
+ if (hasOwnedAdmission) delete env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST;
635
637
  const providerNames = Object.keys(config.providers);
636
638
  for (const name of MODEL_ENV_SLOT_NAMES) {
637
639
  const value = env[name];
@@ -31,7 +31,7 @@ export interface CodexCliUpdateCommandDeps {
31
31
  readonly inspectIdentity?: (input: CodexCliInstallationIdentityInput) => Promise<CodexCliInstallationIdentityReport>;
32
32
  readonly deriveInstallationInput?: (
33
33
  snapshot: CodexCliInstallationSnapshot,
34
- ) => CodexCliInstallationTargetDerivation;
34
+ ) => Promise<CodexCliInstallationTargetDerivation>;
35
35
  }
36
36
 
37
37
  function identitySummary(report: CodexCliInstallationIdentityReport): string[] {
@@ -148,7 +148,7 @@ export async function handleCodexCliUpdateCommand(
148
148
  const snapshot = trustedNodeLauncherContext()?.codexCliInspectionEnv;
149
149
  const derive = deps.deriveInstallationInput
150
150
  ?? (await import("../codex/cli-installation-targets")).deriveCodexCliInstallationInput;
151
- const derived = derive({
151
+ const derived = await derive({
152
152
  codexCliPath: snapshot?.codexCliPath ?? null,
153
153
  path: snapshot?.path ?? null,
154
154
  pathExt: snapshot?.pathExt ?? null,
@@ -22,6 +22,9 @@ export function skipsCodexShimAutoRestore(command: string | undefined, args: str
22
22
  // `resolve` is read-only inspection for embedding shells: a lookup made to populate
23
23
  // a consent surface must not trigger a shim repair side effect first.
24
24
  if (command === "resolve") return true;
25
+ // The desktop's approval-bound stop must validate its snapshot before any unrelated
26
+ // CLI repair changes the installation it was approved against.
27
+ if (command === "stop" && args.some(arg => arg.startsWith("--expect-"))) return true;
25
28
  // The entire updater-inspection namespace is zero-effect, including malformed
26
29
  // or future actions. A later `apply` implementation must own its preflight.
27
30
  if (command === "system" && args[1] === "codex-cli-update") return true;
@@ -30,6 +30,7 @@ import { handleRestartScopeAfterWrite, readRestartScope, type RestartScope } fro
30
30
  import { normalizeUpdateChannel, runGuiUpdateWorker } from "../update/job";
31
31
  import { isJsonOption, takeFlag, terminalSafeError } from "./runtime-api";
32
32
  import { printStopSummary, type StopOutcome } from "./stop-report";
33
+ import { parseStopApproval, type StopApproval } from "./stop-approval";
33
34
  import type { ResolveArgs } from "./resolve";
34
35
  import type { ClientConnectionState } from "../client/state";
35
36
  import { OCX_NATIVE_REPLAY_RECOVERY_NOTE } from "../responses/compaction";
@@ -46,7 +47,7 @@ export interface CliDispatchDeps {
46
47
  /** Spawn a detached proxy child (stdio ignore, unref'd, provenance env). */
47
48
  spawnDetached: (argv: readonly string[]) => void;
48
49
  handleStart: () => Promise<void>;
49
- handleStop: () => Promise<StopOutcome>;
50
+ handleStop: (approval?: StopApproval) => Promise<StopOutcome>;
50
51
  handleEnsure: (options?: { existingIsSuccess?: boolean }) => Promise<boolean>;
51
52
  handleResolve: (args: ResolveArgs) => Promise<number>;
52
53
  handleTrayProxyStart: (existingIsSuccess?: boolean) => Promise<boolean>;
@@ -99,10 +100,15 @@ const commandRunners: Record<string, CommandRunner> = {
99
100
  return Number(process.exitCode ?? 0);
100
101
  },
101
102
  stop: async deps => {
103
+ const parsed = parseStopApproval(deps.args.slice(1));
104
+ if (!parsed.ok) {
105
+ console.error("Usage: ocx stop [--json [--expect-pid <pid> --expect-port <port> --expect-hostname <host> --expect-config-home <home> --expect-cli-version <version> --expect-compatibility-token <hex>]]");
106
+ return 64;
107
+ }
102
108
  // Downtime warning lives HERE, not in handleStop: `restart`/tray-restart callers
103
109
  // re-start the proxy immediately, so warning there would contradict the next line.
104
110
  const warning = "⚠️ Codex/Claude requests through the proxy will fail until it is restarted ('ocx start' or 'ocx service start').";
105
- if (!takeFlag(deps.args.slice(1), "--json")) {
111
+ if (!parsed.json) {
106
112
  // handleStop returns the structured outcome now; an object is always truthy, so
107
113
  // the warning must key on .ok — otherwise a failed stop would still claim downtime.
108
114
  if ((await deps.handleStop()).ok) console.log(warning);
@@ -117,7 +123,7 @@ const commandRunners: Record<string, CommandRunner> = {
117
123
  console.log = console.error;
118
124
  let outcome: StopOutcome | undefined;
119
125
  try {
120
- outcome = await deps.handleStop();
126
+ outcome = await deps.handleStop(parsed.approval ?? undefined);
121
127
  if (outcome.ok) console.log(warning);
122
128
  } finally {
123
129
  console.log = humanLog;
@@ -125,7 +131,9 @@ const commandRunners: Record<string, CommandRunner> = {
125
131
  // A throw above propagates after the finally restores the console, so reaching here
126
132
  // with an undefined outcome cannot happen; the guard keeps the assignment provable.
127
133
  if (outcome) printStopSummary(outcome.summary);
128
- return Number(process.exitCode ?? 0);
134
+ // A guarded refusal never reaches the code that records process.exitCode, so the
135
+ // approval-bound form answers with its summary's code; plain stop keeps its contract.
136
+ return parsed.approval ? (outcome?.summary.exitCode ?? 1) : Number(process.exitCode ?? 0);
129
137
  },
130
138
  resolve: async deps => {
131
139
  // Same fail-closed shape as `ready`: parseCliHead pre-parsed the verb before any