@bitkyc08/opencodex 2.81.0 → 2.82.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 (212) hide show
  1. package/bin/ocx.mjs +166 -73
  2. package/gui/dist/assets/App-Ds_qL7o8.js +54 -0
  3. package/gui/dist/assets/{Tray-EwAOsP0S.js → Tray-BqHF5Trd.js} +1 -1
  4. package/gui/dist/assets/{index-Brk6F3fn.css → index-BAbcj51J.css} +1 -1
  5. package/gui/dist/assets/{index-RsoaAxXY.js → index-D3FBCNiP.js} +13 -13
  6. package/gui/dist/assets/{quota-summary-D2lN8tDm.js → quota-summary-DTBC8n7-.js} +1 -1
  7. package/gui/dist/index.html +2 -2
  8. package/package.json +1 -1
  9. package/src/adapters/anthropic.ts +1 -1
  10. package/src/adapters/openai-responses/compaction-search-history.ts +109 -0
  11. package/src/adapters/openai-responses/passthrough.ts +9 -4
  12. package/src/adapters/openai-responses/tool-output-recovery.ts +20 -0
  13. package/src/claude/agents-inject.ts +14 -3
  14. package/src/claude/context-windows.ts +19 -17
  15. package/src/claude/desktop-3p.ts +88 -12
  16. package/src/claude/desktop-profile.ts +2 -1
  17. package/src/claude/gateway-cache.ts +18 -1
  18. package/src/claude/inbound.ts +77 -3
  19. package/src/claude/intercept/cli-catalog.ts +3 -0
  20. package/src/claude/intercept/picker-ca-persistence.ts +7 -6
  21. package/src/claude/intercept/picker-models.ts +14 -6
  22. package/src/claude/intercept/settings.ts +3 -0
  23. package/src/claude/long-context.ts +66 -0
  24. package/src/claude/model-info.ts +19 -16
  25. package/src/claude/outbound.ts +94 -9
  26. package/src/claude/subagent-model.ts +40 -11
  27. package/src/cli/agent.ts +9 -5
  28. package/src/cli/aside-profile-recovery.ts +33 -0
  29. package/src/cli/capabilities-base.ts +5 -5
  30. package/src/cli/capabilities-integrations.ts +2 -2
  31. package/src/cli/cli-path-diagnostics.ts +132 -0
  32. package/src/cli/desktop-runtime-guidance.ts +65 -0
  33. package/src/cli/dispatch.ts +5 -0
  34. package/src/cli/doctor.ts +16 -3
  35. package/src/cli/index.ts +14 -9
  36. package/src/cli/integration-aside-sync.ts +2 -1
  37. package/src/cli/integrations.ts +16 -3
  38. package/src/cli/resolve.ts +25 -2
  39. package/src/cli/root.ts +4 -2
  40. package/src/cli/status.ts +64 -12
  41. package/src/cli/update-restart-child.ts +4 -2
  42. package/src/cli/update-restart-home.ts +28 -10
  43. package/src/cli/update-restart-service-record.ts +116 -0
  44. package/src/cli/update-restart-supervision.ts +96 -0
  45. package/src/cli/update-restart.ts +59 -13
  46. package/src/cli/v2.ts +27 -20
  47. package/src/cli/version-skew-notice.ts +77 -0
  48. package/src/clients/lazycodex.ts +1 -1
  49. package/src/clients/omo-role-models.ts +32 -7
  50. package/src/codex/auth-api/account-selection.ts +107 -0
  51. package/src/codex/auth-api/routes.ts +10 -49
  52. package/src/codex/auth-context.ts +20 -1
  53. package/src/codex/autostart-health.ts +12 -3
  54. package/src/codex/catalog/build-entries.ts +18 -0
  55. package/src/codex/catalog/bundled.ts +155 -26
  56. package/src/codex/catalog/combo-member.ts +3 -2
  57. package/src/codex/catalog/effort.ts +26 -5
  58. package/src/codex/catalog/gather-capture.ts +5 -0
  59. package/src/codex/catalog/model-hints.ts +15 -8
  60. package/src/codex/catalog/model-visibility.ts +3 -1
  61. package/src/codex/catalog/operator-tiers.ts +38 -0
  62. package/src/codex/catalog/provider-models.ts +43 -35
  63. package/src/codex/catalog/retained-sync.ts +7 -5
  64. package/src/codex/catalog/routed-gather.ts +15 -1
  65. package/src/codex/catalog-auto-refresh-sources.ts +3 -4
  66. package/src/codex/catalog-auto-refresh.ts +19 -0
  67. package/src/codex/catalog.ts +1 -1
  68. package/src/codex/codex-home-owner.ts +3 -0
  69. package/src/codex/config-write-lock.ts +333 -0
  70. package/src/codex/convergence.ts +2 -1
  71. package/src/codex/features.ts +257 -200
  72. package/src/codex/inject/config-write-section.ts +34 -0
  73. package/src/codex/inject/multi-agent-v2.ts +14 -6
  74. package/src/codex/inject/remove.ts +36 -11
  75. package/src/codex/inject/restore.ts +85 -20
  76. package/src/codex/inject-coordination.ts +15 -6
  77. package/src/codex/inject.ts +62 -20
  78. package/src/codex/journal.ts +62 -17
  79. package/src/codex/main-account-cache.ts +43 -0
  80. package/src/codex/paths.ts +2 -0
  81. package/src/codex/prepared-home.ts +18 -0
  82. package/src/codex/prompt-journal.ts +25 -12
  83. package/src/codex/prompt-layers.ts +49 -26
  84. package/src/codex/prompt-lock-claim.ts +134 -0
  85. package/src/codex/prompt-lock-evidence.ts +26 -0
  86. package/src/codex/prompt-lock-io.ts +16 -0
  87. package/src/codex/prompt-lock-owner.ts +130 -0
  88. package/src/codex/prompt-lock.ts +84 -53
  89. package/src/codex/routing/failure-window.ts +146 -0
  90. package/src/codex/routing/selection.ts +4 -1
  91. package/src/codex/routing.ts +13 -17
  92. package/src/codex/runtime.ts +106 -18
  93. package/src/codex/shim.ts +45 -19
  94. package/src/combos/jev-service-exchange.ts +281 -0
  95. package/src/combos/jev.ts +34 -220
  96. package/src/config/atomic-write.ts +1 -1
  97. package/src/config/codex-ws-reuse-setting.ts +10 -0
  98. package/src/config/live-reconcile.ts +17 -0
  99. package/src/config/load-degrade.ts +8 -0
  100. package/src/config/rebase-provenance.ts +11 -0
  101. package/src/config/schema/config-schema.ts +4 -0
  102. package/src/generated/compatibility-version.json +303 -171
  103. package/src/generated/model-metadata.ts +2 -2
  104. package/src/grok/inject.ts +50 -4
  105. package/src/grok/sync.ts +2 -1
  106. package/src/images/loop.ts +29 -11
  107. package/src/integrations/aside-profiles.ts +10 -3
  108. package/src/integrations/catalog-refresh.ts +18 -2
  109. package/src/integrations/writer.ts +9 -3
  110. package/src/lib/bun-path-runtime.d.mts +32 -0
  111. package/src/lib/bun-path-runtime.mjs +95 -0
  112. package/src/lib/bun-runtime-preflight.ts +140 -0
  113. package/src/lib/desktop-cli-handoff.d.mts +20 -0
  114. package/src/lib/desktop-cli-handoff.mjs +92 -0
  115. package/src/lib/desktop-cli-record.d.mts +29 -0
  116. package/src/lib/desktop-cli-record.mjs +102 -0
  117. package/src/lib/request-execution-budget.ts +8 -0
  118. package/src/lib/synced-state-location.ts +46 -6
  119. package/src/lib/test-home-guard.ts +87 -1
  120. package/src/lib/translator-budget.ts +17 -4
  121. package/src/lib/windows-atomic-replace.ts +38 -16
  122. package/src/lib/windows-elevation.ts +6 -0
  123. package/src/lib/windows-secret-acl.ts +92 -25
  124. package/src/lib/winsw.ts +2 -3
  125. package/src/oauth/anthropic-account-refusal.ts +39 -1
  126. package/src/oauth/anthropic-routing.ts +1 -1
  127. package/src/oauth/anthropic-send-ownership.ts +3 -3
  128. package/src/oauth/chatgpt.ts +113 -41
  129. package/src/oauth/index.ts +6 -2
  130. package/src/oauth/store.ts +1 -1
  131. package/src/providers/azure-model-metadata.ts +115 -0
  132. package/src/providers/derive.ts +21 -0
  133. package/src/providers/quota/vendor-probes-key.ts +127 -17
  134. package/src/providers/quota.ts +1 -1
  135. package/src/responses/reasoning-envelope.ts +29 -1
  136. package/src/responses/reasoning-replay-cache.ts +17 -1
  137. package/src/responses/spill-store.ts +15 -2
  138. package/src/responses/state/metrics.ts +8 -0
  139. package/src/responses/state/spill-failure.ts +12 -0
  140. package/src/responses/state/spill-queue.ts +12 -5
  141. package/src/responses/state.ts +18 -10
  142. package/src/responses/task-input.ts +17 -0
  143. package/src/server/background-lifecycle.ts +27 -0
  144. package/src/server/chat-completions.ts +1 -0
  145. package/src/server/chat-native.ts +28 -12
  146. package/src/server/claude-messages.ts +43 -6
  147. package/src/server/inbound-body-admission.ts +1 -0
  148. package/src/server/index/claude-intercept-lifecycle.ts +3 -1
  149. package/src/server/index/serve-options.ts +10 -2
  150. package/src/server/index/spend-ledger-lifecycle.ts +2 -1
  151. package/src/server/index/startup-warnings.ts +19 -0
  152. package/src/server/index.ts +1 -0
  153. package/src/server/management/agent-settings-routes.ts +93 -32
  154. package/src/server/management/codex-agent-role-routes.ts +4 -2
  155. package/src/server/management/codex-prompt-routes.ts +1 -0
  156. package/src/server/management/codex-role-auto-assign.ts +13 -2
  157. package/src/server/management/config-routes.ts +59 -8
  158. package/src/server/management/context.ts +2 -2
  159. package/src/server/management/shared.ts +3 -3
  160. package/src/server/messages-native-oauth.ts +9 -8
  161. package/src/server/messages-native.ts +49 -13
  162. package/src/server/messages-response-headers.ts +14 -0
  163. package/src/server/request-log-conversation.ts +48 -5
  164. package/src/server/request-log.ts +31 -2
  165. package/src/server/responses/adapter-continuation.ts +2 -2
  166. package/src/server/responses/adapter-dispatch.ts +34 -13
  167. package/src/server/responses/codex-ws-exchange.ts +17 -2
  168. package/src/server/responses/codex-ws-pool.ts +100 -13
  169. package/src/server/responses/codex-ws-wire.ts +9 -0
  170. package/src/server/responses/combo-requested-effort.ts +3 -1
  171. package/src/server/responses/core-codex-account.ts +22 -1
  172. package/src/server/responses/core-combo-native.ts +20 -3
  173. package/src/server/responses/core-combo.ts +22 -14
  174. package/src/server/responses/core-lifetime.ts +3 -36
  175. package/src/server/responses/core-options.ts +5 -0
  176. package/src/server/responses/core-replay.ts +73 -0
  177. package/src/server/responses/core.ts +3 -3
  178. package/src/server/responses/fetch-helpers.ts +26 -3
  179. package/src/server/responses/passthrough-delivery.ts +24 -2
  180. package/src/server/responses/passthrough-dispatch.ts +19 -2
  181. package/src/server/responses/request-prepare.ts +4 -0
  182. package/src/server/responses/request-send-budget.ts +112 -49
  183. package/src/server/responses/response-effects.ts +1 -0
  184. package/src/server/responses/run-turn-execution.ts +2 -0
  185. package/src/server/responses/sidecar-execution.ts +25 -16
  186. package/src/server/responses/sidecar-send-budget.ts +58 -0
  187. package/src/server/responses/ws-upstream.ts +19 -6
  188. package/src/server/startup-health-cache.ts +2 -1
  189. package/src/server/workflow-refusal.ts +3 -0
  190. package/src/service/cli.ts +20 -4
  191. package/src/service/desktop-command-guard.ts +32 -0
  192. package/src/service/desktop-startup.ts +66 -40
  193. package/src/service/desktop-supervision.d.mts +26 -0
  194. package/src/service/desktop-supervision.mjs +140 -0
  195. package/src/service/managing-cli.ts +1 -0
  196. package/src/service/orchestration.ts +46 -20
  197. package/src/service/repair.ts +25 -9
  198. package/src/service/state.ts +4 -3
  199. package/src/service/systemd.ts +17 -8
  200. package/src/service/windows-ops.ts +61 -22
  201. package/src/storage/policy-job.ts +5 -1
  202. package/src/storage/policy-worker.ts +3 -1
  203. package/src/storage/policy.ts +9 -0
  204. package/src/types/config.ts +21 -0
  205. package/src/types/request.ts +5 -0
  206. package/src/types/wire.ts +1 -0
  207. package/src/update/index.ts +57 -9
  208. package/src/update/restart-ownership.ts +10 -3
  209. package/src/update/runtime-ownership.d.mts +6 -0
  210. package/src/update/runtime-ownership.mjs +23 -7
  211. package/src/web-search/loop.ts +5 -2
  212. package/gui/dist/assets/App-7yhoys8a.js +0 -54
@@ -20,7 +20,7 @@ import {
20
20
  type TranslatorBudget,
21
21
  } from "../lib/translator-budget";
22
22
  import { sseFieldOffset, sseFieldValue } from "../lib/sse-decoder";
23
- import { decodeReasoningEnvelope, encodeReasoningEnvelope } from "../responses/reasoning-envelope";
23
+ import { decodeReasoningEnvelope, encodeReasoningEnvelope, OCX_REASONING_PREFIX } from "../responses/reasoning-envelope";
24
24
 
25
25
  type Rec = Record<string, unknown>;
26
26
 
@@ -70,8 +70,67 @@ export function anthropicErrorType(status: number): string {
70
70
  }
71
71
  }
72
72
 
73
+ /**
74
+ * Claude Code compacts reactively only when an oversized-prompt refusal is worded the way the
75
+ * Anthropic API words it: it looks for `prompt is too long` (or `input is too long for requested
76
+ * model`) in the message and reads `prompt is too long: <actual> tokens > <limit> maximum` for the
77
+ * gap (2.1.288 binary). Routed providers word the same refusal differently, so a
78
+ * context_length_exceeded envelope is rewritten into that form. Counts are carried over only when
79
+ * the upstream states both of them; otherwise the original text is prefixed and no number is
80
+ * guessed. Without this the client ends the turn on a fatal error instead of compacting
81
+ * (devlog/_plan/261009_claude_1m_default/010).
82
+ */
83
+ const CLAUDE_PROMPT_TOO_LONG_RE = /prompt is too long|input is too long for requested model/i;
84
+ const STATED_CONTEXT_COUNTS_RE = /maximum context length is (\d+) tokens[\s\S]*?(?:resulted in|you requested) (\d+) tokens/i;
85
+
86
+ export function claudePromptTooLongMessage(message: string): string {
87
+ if (CLAUDE_PROMPT_TOO_LONG_RE.test(message)) return message;
88
+ const counts = STATED_CONTEXT_COUNTS_RE.exec(message);
89
+ if (counts) return `prompt is too long: ${counts[2]} tokens > ${counts[1]} maximum`;
90
+ return `prompt is too long: ${message}`;
91
+ }
92
+
93
+ /** Provider wordings of an oversized-input refusal, for lanes that relay a provider's own error text. */
94
+ const CONTEXT_OVERFLOW_TEXT_RE = /prompt is too long|input is too long|context window|context length|maximum context|too many tokens|model token limit/i;
95
+
96
+ export function isContextOverflowText(message: string): boolean {
97
+ return CONTEXT_OVERFLOW_TEXT_RE.test(message);
98
+ }
99
+
100
+ /**
101
+ * SSE payload rewrite for the native Messages lane: an `error` event that refuses an oversized
102
+ * input gets the Anthropic wording; every other payload passes through byte-identical. The type
103
+ * gate keeps a rate limit that happens to mention the context window untouched.
104
+ */
105
+ export function claudeOverflowSsePayload(payload: string): string {
106
+ if (!payload.includes("\"error\"")) return payload;
107
+ let parsed: unknown;
108
+ try { parsed = JSON.parse(payload); } catch { return payload; }
109
+ if (!isRec(parsed) || parsed.type !== "error" || !isRec(parsed.error)) return payload;
110
+ const error = parsed.error;
111
+ const message = typeof error.message === "string" ? error.message : "";
112
+ const sized = error.type === "invalid_request_error" || error.type === "request_too_large";
113
+ if (!sized || isThroughputLimitText(message)) return payload;
114
+ if (!(error.code === "context_length_exceeded" || isContextOverflowText(message))) return payload;
115
+ const next = claudePromptTooLongMessage(message);
116
+ return next === message ? payload : JSON.stringify({ ...parsed, error: { ...error, message: next } });
117
+ }
118
+
119
+ /**
120
+ * A throughput limit that the shared classifier files under context_length_exceeded (its "too many
121
+ * tokens" match also catches "too many tokens per minute"). Worded as an overflow it would make
122
+ * Claude Code compact a conversation that only needs to wait, so it keeps its own text.
123
+ */
124
+ const THROUGHPUT_LIMIT_TEXT_RE = /\bper (?:second|minute|hour|day)\b|\brate limit|\bquota\b|\btpm\b/i;
125
+
126
+ export function isThroughputLimitText(message: string): boolean {
127
+ return THROUGHPUT_LIMIT_TEXT_RE.test(message);
128
+ }
129
+
73
130
  export function anthropicErrorBody(status: number, message: string, type?: string, code?: string): Rec {
74
- return { type: "error", error: { type: type ?? anthropicErrorType(status), message, ...(code ? { code } : {}) } };
131
+ const overflow = code === "context_length_exceeded" && !isThroughputLimitText(message);
132
+ const text = overflow ? claudePromptTooLongMessage(message) : message;
133
+ return { type: "error", error: { type: type ?? anthropicErrorType(status), message: text, ...(code ? { code } : {}) } };
75
134
  }
76
135
 
77
136
  export function anthropicErrorResponse(status: number, message: string, type?: string, code?: string): Response {
@@ -280,6 +339,13 @@ interface OpenBlock {
280
339
  thinkingBuf?: string;
281
340
  thinkingBufBytes?: number;
282
341
  reasoningSig?: string;
342
+ /** The provider's own encrypted reasoning for this item; signed into the block on close. */
343
+ reasoningNative?: { enc: string; id?: string };
344
+ }
345
+
346
+ /** A provider-minted (not ocxr1) `encrypted_content`, or `""`. */
347
+ function nativeEncryptedReasoning(encrypted: string): string {
348
+ return encrypted.length > 0 && !encrypted.startsWith(OCX_REASONING_PREFIX) ? encrypted : "";
283
349
  }
284
350
 
285
351
  /** Streaming: Responses SSE bytes -> Anthropic Messages SSE bytes. */
@@ -294,6 +360,7 @@ export function responsesSseToAnthropicSse(
294
360
  * upstream sent no confirmed usage before the first frame. See `messageSnapshot` (#4857).
295
361
  */
296
362
  inputTokenFloor?: number;
363
+ nativeReasoningTagFor?: (blob: string) => string | undefined;
297
364
  },
298
365
  ): ReadableStream<Uint8Array> {
299
366
  const translatorBudget = opts.translatorBudget;
@@ -382,6 +449,13 @@ export function responsesSseToAnthropicSse(
382
449
  open.webSearchArgsEmitted = true;
383
450
  }
384
451
  if (open.kind === "thinking") {
452
+ const native = open.reasoningNative;
453
+ const tag = native && opts.nativeReasoningTagFor?.(native.enc);
454
+ if (!open.thinkingBuf && !open.reasoningSig && !tag) {
455
+ releaseThinkingBuffer(open);
456
+ open = null;
457
+ return;
458
+ }
385
459
  // Delay the index and all thinking frames until closure so a matching
386
460
  // done envelope can put its redacted blocks first. The existing buffer
387
461
  // remains charged through signature emission, including queued frames.
@@ -396,7 +470,10 @@ export function responsesSseToAnthropicSse(
396
470
  delta: { type: "thinking_delta", thinking: open.thinkingBuf },
397
471
  });
398
472
  }
399
- const signature = open.reasoningSig ?? encodeReasoningEnvelope({ txt: open.thinkingBuf ?? "" }, translatorBudget);
473
+ const signature = open.reasoningSig ?? encodeReasoningEnvelope({
474
+ txt: open.thinkingBuf ?? "",
475
+ ...(tag ? { nat: { ...native, model, tag } } : {}),
476
+ }, translatorBudget);
400
477
  emit("content_block_delta", {
401
478
  type: "content_block_delta", index: open.index,
402
479
  delta: { type: "signature_delta", signature },
@@ -684,11 +761,15 @@ export function responsesSseToAnthropicSse(
684
761
  emit("content_block_start", { type: "content_block_start", index: idx, content_block: { type: "redacted_thinking", data } });
685
762
  emit("content_block_stop", { type: "content_block_stop", index: idx });
686
763
  }
687
- if (env?.sig && open?.kind !== "thinking") {
764
+ // A provider's own blob is the only copy of its reasoning the client can hold, so
765
+ // it gets a thinking block even when no summary text arrived.
766
+ const native = nativeEncryptedReasoning(encrypted);
767
+ if ((env?.sig || native) && open?.kind !== "thinking") {
688
768
  ensureBlock("thinking");
689
769
  }
690
770
  if (open?.kind === "thinking") {
691
771
  if (env?.sig) open.reasoningSig = env.sig;
772
+ if (native) open.reasoningNative = typeof item.id === "string" && item.id.length > 0 ? { enc: native, id: item.id } : { enc: native };
692
773
  closeOpenBlock();
693
774
  }
694
775
  }
@@ -881,7 +962,7 @@ export function responsesSseToAnthropicSse(
881
962
  }
882
963
 
883
964
  /** Non-streaming: /v1/responses JSON -> Anthropic message JSON. */
884
- export function responsesJsonToAnthropicMessage(json: unknown, model: string, translatorBudget?: TranslatorBudget): Rec {
965
+ export function responsesJsonToAnthropicMessage(json: unknown, model: string, translatorBudget?: TranslatorBudget, nativeReasoningTagFor?: (blob: string) => string | undefined): Rec {
885
966
  const body = isRec(json) ? json : {};
886
967
  const output = Array.isArray(body.output) ? body.output : [];
887
968
  const content: Rec[] = [];
@@ -917,10 +998,14 @@ export function responsesJsonToAnthropicMessage(json: unknown, model: string, tr
917
998
  // Legacy combined envelopes place redacted blocks before the signed block,
918
999
  // matching the Anthropic adapter. New bridge output uses separate items.
919
1000
  for (const data of env?.red ?? []) content.push({ type: "redacted_thinking", data });
920
- // env.txt may be locally hidden text. Do not expose it here or manufacture
921
- // a new signed continuity carrier; hidden-summary replay remains limited.
922
- if (parts.length > 0 || env?.sig) {
923
- content.push({ type: "thinking", thinking: parts.join("\n\n"), signature: env?.sig ?? encodeReasoningEnvelope({ txt: parts.join("\n\n") }, translatorBudget) });
1001
+ // env.txt may be locally hidden text. Do not expose it here. A provider's own blob is
1002
+ // carried in the envelope so the next request can hand it back (see inbound.ts).
1003
+ const native = nativeEncryptedReasoning(encrypted);
1004
+ const tag = native && nativeReasoningTagFor?.(native);
1005
+ if (parts.length > 0 || env?.sig || tag) {
1006
+ const text = parts.join("\n\n");
1007
+ const nat = tag ? { enc: native, model, tag, ...(typeof raw.id === "string" && raw.id.length > 0 ? { id: raw.id } : {}) } : undefined;
1008
+ content.push({ type: "thinking", thinking: text, signature: env?.sig ?? encodeReasoningEnvelope({ txt: text, ...(nat ? { nat } : {}) }, translatorBudget) });
924
1009
  }
925
1010
  break;
926
1011
  }
@@ -1,6 +1,7 @@
1
1
  import type { OcxConfig } from "../types";
2
2
  import { claudeCodeAlias, claudeCodeNativeAlias, resolveAlias } from "./alias";
3
- import { AUTO_CONTEXT_OFF, shouldMarkOneMillion, stripOneMillionMarker, withOneMillionMarker } from "./context-windows";
3
+ import { AUTO_CONTEXT_OFF, shouldMarkOneMillion, stripOneMillionMarker, UNPAIRED_AUTO_CONTEXT, withOneMillionMarker, type AutoContextMode } from "./context-windows";
4
+ import { isAnthropicClaudeRoute } from "./long-context";
4
5
  import { hasOwnProvider } from "../config";
5
6
  import { knownModelIdsForProvider } from "../router";
6
7
  import { decodeRoutedModelIdOrThrow } from "../providers/slug-codec";
@@ -14,20 +15,43 @@ export { SAFE_AGENT_MODEL_ID } from "../config/subagent-models";
14
15
 
15
16
  /**
16
17
  * Generated subagent defs cannot rely on the parent's auto-context compaction
17
- * pairing, so their [1m] marker follows the AUTHORITATIVE window only: mark when
18
- * the effective window (exact selector, then the canonical [1m] form, then bare)
19
- * is genuinely >= 1M; strip an inherited unsafe marker back to the bare selector;
20
- * with no window information, keep the selector as it was. Genuine routed [1m]
21
- * ids are preserved through the canonical-exact lookup. (#854)
18
+ * pairing, so their [1m] marker follows the unpaired long-context rule
19
+ * (long-context.ts): mark when the effective window (exact selector, then the
20
+ * canonical [1m] form, then bare) is >= 1M or can host the default compact window,
21
+ * whose overflow Claude Code compacts on as `prompt is too long`; strip an inherited
22
+ * marker the window cannot carry; with no window information, keep the selector as it
23
+ * was. Genuine routed [1m] ids are preserved through the canonical-exact lookup.
24
+ * (#854; devlog/_plan/261009_claude_1m_default/020)
22
25
  */
23
- export function withSubagentContextMarker(selector: string, windows: Record<string, number>): string {
26
+ /**
27
+ * The marking mode a subagent selector gets: real Anthropic models (a bare `claude-*` passthrough
28
+ * id, or a Claude row on either Anthropic pool) need a genuine 1M; everything else follows the
29
+ * unpaired long-context rule. Connected launches read windows from the published catalog, which
30
+ * does not drop capped Anthropic rows, so this cannot rely on the window map alone.
31
+ */
32
+ function subagentSelectorMode(bare: string): AutoContextMode {
33
+ const route = resolveAlias(bare) ?? resolveDesktop3pAlias(bare) ?? bare;
34
+ const slash = route.indexOf("/");
35
+ const anthropic = slash < 0
36
+ ? route.startsWith("claude-")
37
+ : isAnthropicClaudeRoute(route.slice(0, slash), route.slice(slash + 1));
38
+ return anthropic ? AUTO_CONTEXT_OFF : UNPAIRED_AUTO_CONTEXT;
39
+ }
40
+
41
+ /**
42
+ * `accounting200k` (claudeCode.contextAccounting "200k") stops automatic marking only: an unmarked
43
+ * selector stays bare, while an explicit [1m] still follows its selector's safety rule.
44
+ */
45
+ export function withSubagentContextMarker(selector: string, windows: Record<string, number>, accounting200k = false): string {
24
46
  const bare = stripOneMillionMarker(selector);
25
47
  const wasMarked = selector !== bare;
26
48
  const canonicalExact = wasMarked ? `${bare}[1m]` : selector;
27
49
  const authoritativeWindow = windows[selector] ?? windows[canonicalExact] ?? windows[bare];
28
50
  if (typeof authoritativeWindow === "number" && authoritativeWindow > 0) {
29
- return shouldMarkOneMillion(authoritativeWindow, AUTO_CONTEXT_OFF)
30
- ? (withOneMillionMarker(selector, windows) ?? selector)
51
+ if (accounting200k && !wasMarked) return bare;
52
+ const mode = subagentSelectorMode(bare);
53
+ return shouldMarkOneMillion(authoritativeWindow, mode)
54
+ ? (withOneMillionMarker(selector, windows, mode) ?? selector)
31
55
  : bare;
32
56
  }
33
57
  return wasMarked ? selector : bare;
@@ -56,6 +80,11 @@ function selectorIdentity(selector: string): string {
56
80
  return fullSelectorIdentity(stripOneMillionMarker(selector));
57
81
  }
58
82
 
83
+ /** Whether the config opted into 200k accounting (shared by roster, self and force markers). */
84
+ export function accountsAt200k(config: OcxConfig): boolean {
85
+ return config.claudeCode?.contextAccounting === "200k";
86
+ }
87
+
59
88
  /** Resolve only currently exposed entries; retained roster rows are not exposure proof. */
60
89
  export function resolveSubagentForceModel(config: OcxConfig, windows: Record<string, number>, available: SubagentForceExposure): string | null {
61
90
  const entry = config.claudeCode?.subagentModelForce;
@@ -77,8 +106,8 @@ export function resolveSubagentForceModel(config: OcxConfig, windows: Record<str
77
106
  const window = windows[parts.alias] ?? windows[`${bare}[1m]`] ?? windows[bare];
78
107
  // Identity admission must not let an appended context promise ride on an
79
108
  // ordinary exposed model. Exact catalog ids such as kimi/k3[1m] remain literal.
80
- if (requestedMarker && !exactMarkedExposure && !(typeof window === "number" && Number.isFinite(window) && window >= 1_000_000)) return null;
81
- const marked = exactMarkedExposure ? parts.alias : withSubagentContextMarker(parts.alias, windows);
109
+ if (requestedMarker && !exactMarkedExposure && !(typeof window === "number" && Number.isFinite(window) && shouldMarkOneMillion(window, subagentSelectorMode(bare)))) return null;
110
+ const marked = exactMarkedExposure ? parts.alias : withSubagentContextMarker(parts.alias, windows, accountsAt200k(config));
82
111
  // Bare Anthropic names are indistinguishable from the old CLI's fallback.
83
112
  // Encode only the force selector; handlers restore native identity before
84
113
  // their existing credential/model-map checks. Roster generation is unchanged.
package/src/cli/agent.ts CHANGED
@@ -30,7 +30,7 @@ const USAGE = `Usage:
30
30
  ocx agent subagents <status|set|clear> [model,model...] [--json]
31
31
  ocx agent subagents force <model|-> [--json]
32
32
  ocx agent fallback <status|set|clear> [model,model...] [--poll-ms <5000-600000>] [--json]
33
- ocx agent roles [status|set <role> <model>|suggest [--model <id>] [--apply]] [--json]
33
+ ocx agent roles [status|set <role> <model> [--effort <level>]|suggest [--model <id>] [--apply]] [--json]
34
34
  ocx agent sidecar <status|web|vision> [--list] [--model <id|->]
35
35
  [--backend web:<openai|anthropic|xai|gemini|exa|-> vision:<openai|anthropic|routed|->]
36
36
  [--reasoning <level>] [--max-descriptions <n> (vision)] [--enabled <on|off>]
@@ -311,7 +311,7 @@ async function sidecar(argv: string[], deps: RuntimeApiDeps): Promise<void> {
311
311
  interface CodexAgentRolesStatus {
312
312
  lazycodex?: { detected?: boolean };
313
313
  omoJsonc?: { state?: string } | null;
314
- roles?: Array<{ role: string; model: string | null; omoJsoncModel: string | null }>;
314
+ roles?: Array<{ role: string; model: string | null; effort?: string | null; omoJsoncModel: string | null }>;
315
315
  }
316
316
 
317
317
  interface CodexRoleProposal {
@@ -393,7 +393,7 @@ async function roles(argv: string[], deps: RuntimeApiDeps): Promise<void> {
393
393
  const rows = result.roles ?? [];
394
394
  printData(result, wantsJson, [
395
395
  "omo (Codex / LazyCodex): detected",
396
- ...(rows.length === 0 ? ["No Codex agent roles found."] : rows.map(row => `${row.role}: ${row.model ?? "(no model pin)"}`)),
396
+ ...(rows.length === 0 ? ["No Codex agent roles found."] : rows.map(row => `${row.role}: ${row.model ?? "(no model pin)"}${row.effort ? ` (${row.effort})` : ""}`)),
397
397
  `omo.jsonc: ${result.omoJsonc?.state ?? "unknown"}`,
398
398
  ]);
399
399
  return;
@@ -403,18 +403,22 @@ async function roles(argv: string[], deps: RuntimeApiDeps): Promise<void> {
403
403
  return;
404
404
  }
405
405
  if (action !== "set") throw new CliUsageError(`unknown roles action ${action}`, USAGE);
406
+ const effort = takeOption(args, "--effort");
407
+ // An empty value is a supplied flag, not an absent one: dropping it would change the model and
408
+ // silently ignore the effort the operator asked for.
409
+ if (effort !== undefined && effort.trim() === "") throw new CliUsageError("--effort needs a reasoning level", USAGE);
406
410
  const role = args.shift();
407
411
  const model = args.shift();
408
412
  if (!role || !model) throw new CliUsageError("a role and a model are required", USAGE);
409
413
  rejectArgs(args, USAGE);
410
414
  const result = await runtimeRequest<{ toml?: { status?: string }; omoJsonc?: { status?: string } }>(
411
415
  `/api/codex-agent-roles/${encodeURIComponent(role)}`,
412
- { method: "PUT", body: JSON.stringify({ model }) },
416
+ { method: "PUT", body: JSON.stringify(effort !== undefined ? { model, effort } : { model }) },
413
417
  deps,
414
418
  );
415
419
  const omo = result.omoJsonc?.status;
416
420
  printData(result, wantsJson, [
417
- `${role}: ${model} (role TOML ${result.toml?.status ?? "unknown"})`,
421
+ `${role}: ${model}${effort ? ` (${effort})` : ""} (role TOML ${result.toml?.status ?? "unknown"})`,
418
422
  omo === "skipped_comments"
419
423
  ? "omo.jsonc: not written, because it contains comments that a rewrite would lose."
420
424
  : `omo.jsonc: ${omo ?? "unknown"}`,
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Recovery guidance for Aside profiles whose sync preference is off while their managed block is stale (#6757).
3
+ *
4
+ * Sync refreshes only profiles whose preference is on, and an OFF preference is the operator's decision, so an
5
+ * "off (stale)" profile needs an explicit, reviewed enable. This module renders text only: it issues no request,
6
+ * reads no state and never changes a preference. Sync cannot name real profile IDs without a status read that may
7
+ * run integration maintenance, so its empty result points at status, which already holds the rows.
8
+ */
9
+ export const ASIDE_SYNC_EMPTY_LINES: readonly string[] = [
10
+ "No eligible Aside profiles to synchronize. Sync refreshes only profiles whose sync preference is on.",
11
+ "List profiles with: ocx integration client status --client aside",
12
+ "For a profile shown as off (stale), review: ocx integration client preview --client aside --operation apply --profile <N>",
13
+ "Then, if the preview permits the change and you accept it: ocx integration client enable --client aside --profile <N>",
14
+ ];
15
+
16
+ function recoveryProfileId(row: unknown): number | null {
17
+ if (!row || typeof row !== "object") return null;
18
+ const { profileId, enabled, state } = row as Record<string, unknown>;
19
+ // Only an explicit OFF beside a stale block qualifies. Conflict and unsafe blocks need inspection, not an enable.
20
+ if (enabled !== false || state !== "stale") return null;
21
+ return typeof profileId === "number" && Number.isSafeInteger(profileId) && profileId >= 0 && !Object.is(profileId, -0)
22
+ ? profileId : null;
23
+ }
24
+
25
+ /** Per-profile preview-then-enable commands for status rows that are off with a stale managed block. */
26
+ export function asideProfileRecoveryLines(rows: readonly unknown[]): string[] {
27
+ const ids = [...new Set(rows.map(recoveryProfileId).filter((id): id is number => id !== null))];
28
+ return ids.flatMap(id => [
29
+ `Aside profile ${id} is off (stale). To reconnect it, review: ocx integration client preview --client aside --operation apply --profile ${id}`,
30
+ `Then, if the preview permits the change and you accept it: ocx integration client enable --client aside --profile ${id}`,
31
+ ]);
32
+ }
33
+
@@ -571,11 +571,11 @@ export const CAPABILITIES: readonly Capability[] = [
571
571
  command: ["claude","config"],
572
572
  summary: "Read or update Claude Code settings, including independent CLI first-party routing.",
573
573
  routes: [{"method":"GET","path":"/api/claude-code"},{"method":"PUT","path":"/api/claude-code"}],
574
- flags: [{"name":"--enabled","value":"string","summary":"on or off."},{"name":"--auth-mode","value":"string","summary":"auto, proxy or subscription."},{"name":"--system-env","value":"string","summary":"on or off."},{"name":"--fast-mode","value":"string","summary":"on or off."},{"name":"--auto-context","value":"string","summary":"on or off."},{"name":"--compact-window","value":"string","summary":"Positive token count or default to clear."},{"name":"--inject-agents","value":"string","summary":"on or off."},{"name":"--small-fast-model","value":"string","summary":"Model ID; - clears."},{"name":"--model-map","value":"string","summary":"Comma-separated from=to mappings; - clears."},{"name":"--blocked-skills","value":"string","summary":"Comma-separated names; - clears."},{"name":"--web-model","value":"string","summary":"Model ID; - clears."},{"name":"--web-backend","value":"string","summary":"openai, anthropic, xai, gemini, exa or -."},{"name":"--vision-model","value":"string","summary":"Model ID; - clears."},{"name":"--vision-backend","value":"string","summary":"openai, anthropic or -."},{"name":"--first-party","value":"string","summary":"on or off; must be the only setting."},{"name":"--json","value":"boolean","summary":"Emit the result as JSON."}],
574
+ flags: [{"name":"--enabled","value":"string","summary":"on or off."},{"name":"--auth-mode","value":"string","summary":"auto, proxy or subscription."},{"name":"--system-env","value":"string","summary":"on or off."},{"name":"--fast-mode","value":"string","summary":"on or off."},{"name":"--auto-context","value":"string","summary":"on or off."},{"name":"--compact-window","value":"string","summary":"Positive token count or default to clear."},{"name":"--context-accounting","value":"string","summary":"1m (default) or 200k."},{"name":"--inject-agents","value":"string","summary":"on or off."},{"name":"--small-fast-model","value":"string","summary":"Model ID; - clears."},{"name":"--model-map","value":"string","summary":"Comma-separated from=to mappings; - clears."},{"name":"--blocked-skills","value":"string","summary":"Comma-separated names; - clears."},{"name":"--web-model","value":"string","summary":"Model ID; - clears."},{"name":"--web-backend","value":"string","summary":"openai, anthropic, xai, gemini, exa or -."},{"name":"--vision-model","value":"string","summary":"Model ID; - clears."},{"name":"--vision-backend","value":"string","summary":"openai, anthropic or -."},{"name":"--first-party","value":"string","summary":"on or off; must be the only setting."},{"name":"--json","value":"boolean","summary":"Emit the result as JSON."}],
575
575
  mutates: true,
576
576
  json: "payload",
577
577
  details: ["At least one setting is required. --first-party must be set alone and writes Claude settings immediately.","Clear both helper model and backend with - to restore inheritance. Preserve shared_proxy_retained warnings."],
578
- usage: "ocx claude config status [--json]; ocx claude config set [--enabled <on|off>] [--auth-mode <auto|proxy|subscription>] [--system-env <on|off>] [--fast-mode <on|off>] [--auto-context <on|off>] [--compact-window <tokens|default>] [--inject-agents <on|off>] [--small-fast-model <id|->] [--model-map <from=to,...|->] [--blocked-skills <name,name|->] [--web-model <id|->] [--web-backend <openai|anthropic|xai|gemini|exa|->] [--vision-model <id|->] [--vision-backend <openai|anthropic|->] [--json]; or ocx claude config set --first-party <on|off> [--json]",
578
+ usage: "ocx claude config status [--json]; ocx claude config set [--enabled <on|off>] [--auth-mode <auto|proxy|subscription>] [--system-env <on|off>] [--fast-mode <on|off>] [--auto-context <on|off>] [--compact-window <tokens|default>] [--context-accounting <1m|200k>] [--inject-agents <on|off>] [--small-fast-model <id|->] [--model-map <from=to,...|->] [--blocked-skills <name,name|->] [--web-model <id|->] [--web-backend <openai|anthropic|xai|gemini|exa|->] [--vision-model <id|->] [--vision-backend <openai|anthropic|->] [--json]; or ocx claude config set --first-party <on|off> [--json]",
579
579
  },
580
580
  {
581
581
  command: ["claude","intercept","start"],
@@ -714,11 +714,11 @@ export const CAPABILITIES: readonly Capability[] = [
714
714
  command: ["agent","roles"],
715
715
  summary: "omo (Codex / LazyCodex): show each Codex agent role's model pin, set one role's model in its TOML and in omo.jsonc, or suggest a model for every role.",
716
716
  routes: [{"method":"GET","path":"/api/codex-agent-roles"},{"method":"PUT","path":"/api/codex-agent-roles/{role}"},{"method":"POST","path":"/api/codex-agent-roles/auto-assign"}],
717
- flags: [{"name":"--json","value":"boolean","summary":"Emit the role list, the write result or the proposals as JSON."},{"name":"--model","value":"string","summary":"suggest: size the roles with this model instead of the Codex default model."},{"name":"--apply","value":"boolean","summary":"suggest: write every proposal through the role model write."}],
717
+ flags: [{"name":"--json","value":"boolean","summary":"Emit the role list, the write result or the proposals as JSON."},{"name":"--effort","value":"string","summary":"set: also write the role's reasoning effort."},{"name":"--model","value":"string","summary":"suggest: size the roles with this model instead of the Codex default model."},{"name":"--apply","value":"boolean","summary":"suggest: write every proposal through the role model write."}],
718
718
  mutates: true,
719
719
  json: "payload",
720
- details: ["A bare invocation reads and never writes.","Requires Codex-based omo (LazyCodex): the omo@sisyphuslabs Codex plugin enabled in config.toml and installed; otherwise status lists no roles, and set and suggest are refused.","set rewrites only the root model value of $CODEX_HOME/agents/<role>.toml; omo.jsonc is skipped when absent or when it contains comments.","suggest sizes every role with one model call and prints proposals without writing; --apply writes each proposed model, and its effort when the role file already sets model_reasoning_effort."],
721
- usage: "ocx agent roles [status] [--json]; ocx agent roles set <role> <model> [--json]; ocx agent roles suggest [--model <id>] [--apply] [--json]",
720
+ details: ["A bare invocation reads and never writes.","Requires Codex-based omo (LazyCodex): the omo@sisyphuslabs Codex plugin enabled in config.toml and installed; otherwise status lists no roles, and set and suggest are refused.","set rewrites only the root model value of $CODEX_HOME/agents/<role>.toml, plus model_reasoning_effort with --effort; omo.jsonc gets the same model and, when LazyCodex has that level, the effort as reasoning; it is skipped when absent or when it contains comments.","suggest sizes every role with one model call and prints proposals without writing; --apply writes each proposed model, and its effort when the role file already sets model_reasoning_effort."],
721
+ usage: "ocx agent roles [status] [--json]; ocx agent roles set <role> <model> [--effort <level>] [--json]; ocx agent roles suggest [--model <id>] [--apply] [--json]",
722
722
  },
723
723
  {
724
724
  command: ["agent","injection"],
@@ -139,10 +139,10 @@ export const INTEGRATION_CAPABILITIES: readonly Capability[] = [
139
139
  },
140
140
  {
141
141
  command: ["claude", "config", "set"],
142
- usage: "ocx claude config set [--enabled <on|off>] [--auth-mode <auto|proxy|subscription>] [--system-env <on|off>] [--fast-mode <on|off>] [--auto-context <on|off>] [--compact-window <tokens|default>] [--inject-agents <on|off>] [--small-fast-model <id|->] [--model-map <from=to,...|->] [--blocked-skills <name,name|->] [--web-model <id|->] [--web-backend <openai|anthropic|xai|gemini|exa|->] [--vision-model <id|->] [--vision-backend <openai|anthropic|->] [--json]; or ocx claude config set --first-party <on|off> [--json]",
142
+ usage: "ocx claude config set [--enabled <on|off>] [--auth-mode <auto|proxy|subscription>] [--system-env <on|off>] [--fast-mode <on|off>] [--auto-context <on|off>] [--compact-window <tokens|default>] [--context-accounting <1m|200k>] [--inject-agents <on|off>] [--small-fast-model <id|->] [--model-map <from=to,...|->] [--blocked-skills <name,name|->] [--web-model <id|->] [--web-backend <openai|anthropic|xai|gemini|exa|->] [--vision-model <id|->] [--vision-backend <openai|anthropic|->] [--json]; or ocx claude config set --first-party <on|off> [--json]",
143
143
  summary: "Change Claude Code settings through the runtime owner.",
144
144
  routes: [{"method": "PUT", "path": "/api/claude-code"}],
145
- flags: [{"name": "--enabled", "value": "string", "summary": "on or off."}, {"name": "--auth-mode", "value": "string", "summary": "auto, proxy or subscription."}, {"name": "--system-env", "value": "string", "summary": "on or off."}, {"name": "--fast-mode", "value": "string", "summary": "on or off."}, {"name": "--auto-context", "value": "string", "summary": "on or off."}, {"name": "--compact-window", "value": "string", "summary": "Positive token count or default to clear."}, {"name": "--inject-agents", "value": "string", "summary": "on or off."}, {"name": "--small-fast-model", "value": "string", "summary": "Model ID; - clears."}, {"name": "--model-map", "value": "string", "summary": "Comma-separated from=to mappings; - clears."}, {"name": "--blocked-skills", "value": "string", "summary": "Comma-separated names; - clears."}, {"name": "--web-model", "value": "string", "summary": "Model ID; - clears."}, {"name": "--web-backend", "value": "string", "summary": "openai, anthropic, xai, gemini, exa or -."}, {"name": "--vision-model", "value": "string", "summary": "Model ID; - clears."}, {"name": "--vision-backend", "value": "string", "summary": "openai, anthropic or -."}, {"name": "--first-party", "value": "string", "summary": "on or off; must be the only setting."}, {"name": "--json", "value": "boolean", "summary": "Emit the result as JSON."}],
145
+ flags: [{"name": "--enabled", "value": "string", "summary": "on or off."}, {"name": "--auth-mode", "value": "string", "summary": "auto, proxy or subscription."}, {"name": "--system-env", "value": "string", "summary": "on or off."}, {"name": "--fast-mode", "value": "string", "summary": "on or off."}, {"name": "--auto-context", "value": "string", "summary": "on or off."}, {"name": "--compact-window", "value": "string", "summary": "Positive token count or default to clear."}, {"name": "--context-accounting", "value": "string", "summary": "1m (default) or 200k."}, {"name": "--inject-agents", "value": "string", "summary": "on or off."}, {"name": "--small-fast-model", "value": "string", "summary": "Model ID; - clears."}, {"name": "--model-map", "value": "string", "summary": "Comma-separated from=to mappings; - clears."}, {"name": "--blocked-skills", "value": "string", "summary": "Comma-separated names; - clears."}, {"name": "--web-model", "value": "string", "summary": "Model ID; - clears."}, {"name": "--web-backend", "value": "string", "summary": "openai, anthropic, xai, gemini, exa or -."}, {"name": "--vision-model", "value": "string", "summary": "Model ID; - clears."}, {"name": "--vision-backend", "value": "string", "summary": "openai, anthropic or -."}, {"name": "--first-party", "value": "string", "summary": "on or off; must be the only setting."}, {"name": "--json", "value": "boolean", "summary": "Emit the result as JSON."}],
146
146
  mutates: true,
147
147
  json: "payload",
148
148
  details: ["At least one setting is required. --first-party must be set alone and writes Claude settings immediately.", "Clear both helper model and backend with - to restore inheritance. Preserve shared_proxy_retained warnings."],
@@ -0,0 +1,132 @@
1
+ import { accessSync, constants, realpathSync, statSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { posix, win32 } from "node:path";
4
+ import { readDesktopCliRecord, type DesktopCliRecordRead, desktopCliRecordPath, DESKTOP_CLI_RECORD_MAX_BYTES } from "../lib/desktop-cli-record.mjs";
5
+ import { redactUserPath } from "../lib/redact";
6
+
7
+ export type CliCommandIssue = Extract<DesktopCliRecordRead, { issue: string }>["issue"]
8
+ | "cleanup-pending"
9
+ | "desktop-target-missing" | "desktop-target-unusable"
10
+ | "expected-command-missing" | "expected-command-unusable"
11
+ | "path-missing" | "path-first-not-desktop" | "path-unreadable"
12
+ | "path-scan-truncated" | "windows-current-directory-shadow";
13
+ export type CliPathCandidate = {
14
+ path: string; realpath: string; pathIndex: number; extension: string;
15
+ };
16
+ export type CliPathDiagnostics = {
17
+ configured: boolean;
18
+ recordPath: string;
19
+ packageHandoff: "enabled" | "disabled-on-windows";
20
+ recordState: DesktopCliRecordRead["state"];
21
+ expectedExecutable: string | null;
22
+ handoffTarget: string | null;
23
+ candidates: CliPathCandidate[];
24
+ pathFirst: CliPathCandidate | null;
25
+ desktopFirstOnPath: boolean | null;
26
+ currentDirectoryCandidate: CliPathCandidate | null;
27
+ shellResolution: "unobserved";
28
+ issues: CliCommandIssue[];
29
+ };
30
+ export type CliPathObservation =
31
+ | { kind: "usable"; realpath: string }
32
+ | { kind: "missing" | "unusable" | "unreadable" };
33
+ export type CliPathDiagnosticOptions = {
34
+ platform?: NodeJS.Platform; env?: NodeJS.ProcessEnv; cwd?: string; home?: string;
35
+ recordRead?: DesktopCliRecordRead;
36
+ observe?: (path: string) => CliPathObservation;
37
+ };
38
+
39
+ export function collectCliPathDiagnostics(options: CliPathDiagnosticOptions = {}): CliPathDiagnostics {
40
+ const platform = options.platform ?? process.platform;
41
+ const env = options.env ?? process.env;
42
+ const api = platform === "win32" ? win32 : posix;
43
+ const cwd = options.cwd ?? process.cwd();
44
+ const home = options.home ?? (platform === "win32" ? env.USERPROFILE || homedir() : homedir());
45
+ const read = options.recordRead ?? readDesktopCliRecord({ platform, env, home, recordPath: desktopCliRecordPath({ platform, env, home }) });
46
+ const configured = read.state === "ready";
47
+ const target = configured && read.state === "ready" ? read.record.cliExecutable : null;
48
+ const expected = configured ? (platform === "win32" ? target : api.join(home, ".opencodex-desktop", "bin", "ocx")) : null;
49
+ const issues: CliCommandIssue[] = [];
50
+ const add = (issue: CliCommandIssue): void => { if (!issues.includes(issue)) issues.push(issue); };
51
+ if (read.state === "invalid" || read.state === "unreadable") add(read.issue);
52
+ if (read.state === "disabled" && read.cleanupPending) add("cleanup-pending");
53
+ const observe = options.observe ?? ((path: string): CliPathObservation => {
54
+ try {
55
+ if (!statSync(path).isFile()) return { kind: "unusable" };
56
+ accessSync(path, platform === "win32" ? constants.F_OK : constants.X_OK);
57
+ return { kind: "usable", realpath: realpathSync(path) };
58
+ } catch (error) {
59
+ const code = (error as NodeJS.ErrnoException).code;
60
+ return { kind: code === "ENOENT" || code === "ENOTDIR" ? "missing" : code === "EACCES" ? "unusable" : "unreadable" };
61
+ }
62
+ });
63
+ const key = (path: string): string => platform === "win32" ? path.toLowerCase() : path;
64
+ const same = (a: string, b: string): boolean => key(a) === key(b);
65
+ const envValue = (name: string): string | undefined => platform === "win32"
66
+ ? env[Object.keys(env).find(key => key.toLowerCase() === name.toLowerCase()) ?? name] : env[name];
67
+ const path = envValue("PATH");
68
+ const rawExtensions = platform === "win32"
69
+ ? (envValue("PATHEXT") ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(ext => /^\.[A-Za-z0-9]+$/.test(ext)) : [];
70
+ const uniqueExtensions = [...new Set(rawExtensions.map(ext => ext.toLowerCase()))];
71
+ const extensions = platform === "win32" ? [...uniqueExtensions.slice(0, 32), ""] : [""];
72
+ if (uniqueExtensions.length > 32) add("path-scan-truncated");
73
+ const directories = path === undefined ? [] : path.split(platform === "win32" ? ";" : ":");
74
+ if (directories.length > 256) add("path-scan-truncated");
75
+ const candidates: CliPathCandidate[] = [];
76
+ const scan = (directory: string, pathIndex: number): CliPathCandidate[] => {
77
+ const result: CliPathCandidate[] = [];
78
+ const unquoted = platform === "win32" && directory.startsWith('"') && directory.endsWith('"') ? directory.slice(1, -1) : directory;
79
+ for (const extension of extensions) {
80
+ const candidate = api.resolve(cwd, unquoted || ".", `ocx${extension}`);
81
+ const found = observe(candidate);
82
+ if (found.kind === "unreadable") add("path-unreadable");
83
+ if (found.kind === "usable") result.push({ path: candidate, realpath: found.realpath, pathIndex, extension });
84
+ }
85
+ return result;
86
+ };
87
+ for (const [index, directory] of directories.slice(0, 256).entries()) candidates.push(...scan(directory, index));
88
+ const pathFirst = candidates[0] ?? null;
89
+ const currentDirectoryCandidate = platform === "win32" ? scan(cwd, -1)[0] ?? null : null;
90
+ let expectedRealpath: string | null = null;
91
+ if (target) {
92
+ const found = observe(target);
93
+ if (found.kind === "missing") add("desktop-target-missing");
94
+ else if (found.kind !== "usable") add("desktop-target-unusable");
95
+ }
96
+ if (expected) {
97
+ const found = observe(expected);
98
+ if (found.kind === "missing") add("expected-command-missing");
99
+ else if (found.kind !== "usable") add("expected-command-unusable");
100
+ else expectedRealpath = found.realpath;
101
+ if (!pathFirst) add("path-missing");
102
+ else if (!same(pathFirst.path, expected) && (!expectedRealpath || !same(pathFirst.realpath, expectedRealpath))) add("path-first-not-desktop");
103
+ if (currentDirectoryCandidate && !same(currentDirectoryCandidate.path, expected)
104
+ && (!expectedRealpath || !same(currentDirectoryCandidate.realpath, expectedRealpath))) add("windows-current-directory-shadow");
105
+ }
106
+ return {
107
+ configured, recordPath: read.path, packageHandoff: platform === "win32" ? "disabled-on-windows" : "enabled",
108
+ recordState: read.state, expectedExecutable: expected, handoffTarget: platform === "win32" ? null : target,
109
+ candidates, pathFirst,
110
+ desktopFirstOnPath: configured && !issues.includes("path-unreadable") && !issues.includes("path-scan-truncated")
111
+ ? Boolean(pathFirst && expected && expectedRealpath
112
+ && (same(pathFirst.path, expected) || same(pathFirst.realpath, expectedRealpath))) : null,
113
+ currentDirectoryCandidate, shellResolution: "unobserved", issues,
114
+ };
115
+ }
116
+
117
+ export function formatCliCommandLine(value: CliPathDiagnostics): string {
118
+ const first = value.pathFirst ? redactUserPath(value.pathFirst.path).replace(/[\x00-\x1f\x7f]/g, "?") : "none observed";
119
+ return `ocx command: PATH first=${first}; Desktop=${!value.configured ? "not configured" : value.desktopFirstOnPath === null ? "unobserved" : value.desktopFirstOnPath ? "first" : "not first"}; ${value.packageHandoff === "disabled-on-windows" ? "package handoff=disabled on Windows (user Path selects Desktop); " : ""}shell=unobserved${value.issues.length ? `; issues=${value.issues.join(",")}` : ""}`;
120
+ }
121
+
122
+ export function formatCliStatusHealthLabel(health: string, value: CliPathDiagnostics, local: boolean): string {
123
+ return `${health}${local ? " (local)" : ""}\n ${formatCliCommandLine(value)}`;
124
+ }
125
+
126
+ export function cliCommandDoctorChecks(value: CliPathDiagnostics): { level: "OK" | "WARN" | "FAIL"; message: string }[] {
127
+ const hard = value.issues.some(issue => issue.startsWith("record-") || issue.startsWith("desktop-target-") || issue.startsWith("expected-command-"));
128
+ const recovery = value.issues.some(issue => issue.startsWith("record-"))
129
+ ? ` The terminal-command record at ${redactUserPath(value.recordPath).replace(/[\x00-\x1f\x7f]/g, "?")} could not be used. Open OpenCodex Desktop to repair the terminal command.${value.packageHandoff === "disabled-on-windows" ? "" : " Or run with OCX_NO_DESKTOP_HANDOFF=1."}${value.issues.includes("record-too-large") ? ` The record limit is ${DESKTOP_CLI_RECORD_MAX_BYTES} bytes.` : ""}`
130
+ : value.issues.includes("cleanup-pending") ? " Open OpenCodex Desktop to finish terminal-command cleanup." : "";
131
+ return [{ level: hard ? "FAIL" : value.issues.length ? "WARN" : "OK", message: formatCliCommandLine(value) + recovery }];
132
+ }
@@ -0,0 +1,65 @@
1
+ import { createSupervisionLatch, inspectDesktopSupervision } from "../service/desktop-supervision.mjs";
2
+ import type { DesktopSupervision, SupervisionInspector } from "../service/desktop-command-guard";
3
+ import { UpdateRestartRequired } from "./update-restart-candidate";
4
+ import { runProxyRestart, type ProxyRestartIo } from "./tray-proxy";
5
+
6
+ function evidenceForTarget(evidence: DesktopSupervision, pid: number | null | undefined): DesktopSupervision {
7
+ // Injected inspectors must obey the same target binding as the production inspector.
8
+ return evidence.kind === "desktop" && pid != null && evidence.runtimePid !== pid
9
+ ? { kind: "unknown", reason: "unrelated-target", desktopSeen: false } : evidence;
10
+ }
11
+
12
+ export function duplicateRuntimeMessage(
13
+ pid: number | null | undefined,
14
+ port: number,
15
+ inspect: SupervisionInspector = inspectDesktopSupervision,
16
+ ): string {
17
+ const latch = createSupervisionLatch();
18
+ const evidence = evidenceForTarget(inspect({ targetPid: pid ?? undefined }), pid);
19
+ if (pid != null && latch.observe(evidence)) {
20
+ const supervisor = evidence.kind === "desktop" ? `, ${evidence.app}` : "";
21
+ return `OpenCodex Desktop supervises the running proxy (pid ${pid}${supervisor}, port ${port}). `
22
+ + "Use the running proxy, or quit OpenCodex before starting it from this CLI.";
23
+ }
24
+ return `⚠️ Proxy already running (PID ${pid ?? "unknown"}, port ${port}). Use 'ocx stop' first.`;
25
+ }
26
+
27
+ export function desktopStopNotice(evidence: DesktopSupervision): string | null {
28
+ const latch = createSupervisionLatch();
29
+ return latch.observe(evidence)
30
+ ? "OpenCodex Desktop may start this proxy again after a short backoff. Use Stop Proxy or Quit in the OpenCodex menu to keep it stopped."
31
+ : null;
32
+ }
33
+
34
+ export function runDesktopAwareProxyRestart(
35
+ io: ProxyRestartIo,
36
+ inspect: SupervisionInspector = inspectDesktopSupervision,
37
+ info: (line: string) => void = console.log,
38
+ ) {
39
+ const latch = createSupervisionLatch();
40
+ let targetPid: number | undefined;
41
+ let supervisedRequest = false;
42
+ return runProxyRestart({
43
+ ...io,
44
+ requestInPlaceRestart: async previous => {
45
+ targetPid = previous.pid ?? undefined;
46
+ const evidence = evidenceForTarget(inspect({ targetPid }), targetPid);
47
+ supervisedRequest = latch.observe(evidence);
48
+ const result = await io.requestInPlaceRestart(previous);
49
+ if (evidence.kind === "desktop" && result.accepted) {
50
+ info("Restart requested; OpenCodex Desktop starts the replacement.");
51
+ }
52
+ if (supervisedRequest && !result.accepted && !result.uncertain && result.error instanceof UpdateRestartRequired) {
53
+ info("OpenCodex Desktop supervises this proxy. Use the app's updater (tray → Check for Updates), or quit OpenCodex before updating and restarting from this CLI.");
54
+ return { accepted: false as const, uncertain: false, error: new Error("restart_desktop_update_required") };
55
+ }
56
+ return result;
57
+ },
58
+ startWhenStopped: async recovering => {
59
+ const blocked = latch.observe(evidenceForTarget(inspect({ targetPid }), targetPid));
60
+ // An accepted supervised handoff keeps its replacement intent through temporary absence.
61
+ if (supervisedRequest || blocked) return { status: "skipped" as const };
62
+ return io.startWhenStopped(recovering);
63
+ },
64
+ });
65
+ }