@bitkyc08/opencodex 2.7.41 → 2.7.42

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 (80) hide show
  1. package/README.md +4 -0
  2. package/gui/dist/assets/index-Bl_VBGoI.js +65 -0
  3. package/gui/dist/assets/index-DfVGuN88.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/adapters/base.ts +6 -0
  7. package/src/adapters/kiro-constants.ts +6 -2
  8. package/src/adapters/kiro-retry.ts +175 -10
  9. package/src/adapters/kiro.ts +172 -85
  10. package/src/adapters/mimo-free.ts +1 -0
  11. package/src/adapters/openai-chat.ts +30 -4
  12. package/src/adapters/openai-responses.ts +90 -12
  13. package/src/bridge.ts +91 -43
  14. package/src/claude/desktop-3p-paths.ts +84 -0
  15. package/src/claude/desktop-3p.ts +29 -2
  16. package/src/cli/access.ts +108 -0
  17. package/src/cli/account-auth.ts +223 -0
  18. package/src/cli/account.ts +9 -1
  19. package/src/cli/agent.ts +184 -0
  20. package/src/cli/combo.ts +119 -0
  21. package/src/cli/config-command.ts +145 -0
  22. package/src/cli/debug.ts +20 -8
  23. package/src/cli/doctor.ts +45 -8
  24. package/src/cli/help.ts +65 -13
  25. package/src/cli/index.ts +108 -7
  26. package/src/cli/integrations.ts +142 -0
  27. package/src/cli/models-runtime.ts +212 -0
  28. package/src/cli/models.ts +9 -10
  29. package/src/cli/observe.ts +117 -0
  30. package/src/cli/provider-runtime.ts +152 -0
  31. package/src/cli/provider.ts +23 -1
  32. package/src/cli/runtime-api.ts +325 -0
  33. package/src/cli/star-prompt.ts +3 -3
  34. package/src/cli/status.ts +17 -0
  35. package/src/cli/system-command.ts +112 -0
  36. package/src/codex/auth-api.ts +3 -2
  37. package/src/codex/catalog/aggregation.ts +113 -18
  38. package/src/codex/catalog/provider-fetch.ts +24 -13
  39. package/src/codex/catalog/sync.ts +20 -8
  40. package/src/codex/catalog.ts +2 -1
  41. package/src/codex/refresh.ts +10 -3
  42. package/src/codex/routing.ts +21 -32
  43. package/src/codex/sync.ts +17 -0
  44. package/src/config.ts +48 -0
  45. package/src/generated/jawcode-model-metadata.ts +2 -1
  46. package/src/grok/inject.ts +184 -4
  47. package/src/grok/status.ts +33 -0
  48. package/src/lib/retry-after.ts +55 -0
  49. package/src/lib/windows-elevation.ts +627 -0
  50. package/src/providers/openai-sidecar.ts +46 -2
  51. package/src/providers/registry.ts +52 -0
  52. package/src/server/auth-cors.ts +6 -0
  53. package/src/server/chat-completions.ts +6 -1
  54. package/src/server/claude-messages.ts +20 -1
  55. package/src/server/images.ts +14 -7
  56. package/src/server/management/agent-settings-routes.ts +10 -4
  57. package/src/server/management/combo-routes.ts +0 -1
  58. package/src/server/management/config-routes.ts +0 -1
  59. package/src/server/management/logs-usage-routes.ts +94 -0
  60. package/src/server/management/model-routes.ts +0 -1
  61. package/src/server/management/oauth-account-routes.ts +0 -1
  62. package/src/server/management/provider-routes.ts +0 -1
  63. package/src/server/management/shared.ts +0 -1
  64. package/src/server/management/system-routes.ts +27 -15
  65. package/src/server/management-api.ts +0 -1
  66. package/src/server/memory-watchdog.ts +54 -10
  67. package/src/server/request-log-conversation.ts +168 -0
  68. package/src/server/request-log.ts +122 -2
  69. package/src/server/responses/core.ts +76 -13
  70. package/src/server/responses/passthrough-error.ts +38 -13
  71. package/src/server/startup-action-control.ts +266 -15
  72. package/src/service.ts +512 -3
  73. package/src/storage/cleanup.ts +1538 -0
  74. package/src/storage/scanner.ts +4 -1
  75. package/src/types.ts +16 -0
  76. package/src/update/job.ts +229 -25
  77. package/src/usage/log.ts +39 -0
  78. package/src/web-search/loop.ts +8 -1
  79. package/gui/dist/assets/index-B2J4t3te.css +0 -1
  80. package/gui/dist/assets/index-BmvM6wRb.js +0 -65
@@ -219,6 +219,28 @@ function stripDisabledReasoningSummaries(
219
219
  };
220
220
  }
221
221
 
222
+ /**
223
+ * Normalize only the delivery enum Codex already emitted. Do not inject a field into callers that
224
+ * did not request summaries, and leave every unconfigured provider/model byte-for-byte unchanged.
225
+ */
226
+ function normalizeConfiguredReasoningSummaryDelivery(
227
+ body: unknown,
228
+ provider: OcxProviderConfig,
229
+ modelId: string,
230
+ ): unknown {
231
+ const delivery = modelRecordValue(provider.modelReasoningSummaryDelivery, modelId);
232
+ if (delivery === undefined || !isPlainObject(body) || !isPlainObject(body.stream_options)) return body;
233
+ if (!Object.hasOwn(body.stream_options, "reasoning_summary_delivery")) return body;
234
+ if (body.stream_options.reasoning_summary_delivery === delivery) return body;
235
+ return {
236
+ ...body,
237
+ stream_options: {
238
+ ...body.stream_options,
239
+ reasoning_summary_delivery: delivery,
240
+ },
241
+ };
242
+ }
243
+
222
244
  /**
223
245
  * Comprehensive Spark compatibility layer. codex-rs emits five tool types (function,
224
246
  * namespace, tool_search, web_search, custom) plus extensions (defer_loading,
@@ -496,26 +518,81 @@ const HOSTED_TOOL_NAME_CONFLICTS: ReadonlyArray<{ hostedType: string; namePrefix
496
518
  * HOSTED_TOOL_NAME_CONFLICTS). Only applies on the API-key platform path: the ChatGPT backend
497
519
  * ("forward" mode) accepts the pair, and stripping there would disable native imagegen. No-op
498
520
  * (returns the original reference) when nothing matches.
521
+ *
522
+ * Codex Desktop Responses Lite requests do not always use top-level `body.tools`; they may place
523
+ * the same declarations in `body.input[]` entries of type `additional_tools`. The platform validates
524
+ * these containers as one tool namespace, so conflict detection must span all of them. The function
525
+ * uses copy-on-write semantics and preserves the original request reference when nothing is removed.
499
526
  */
500
527
  function stripConflictingHostedTools(body: unknown): unknown {
501
- if (!isPlainObject(body) || !Array.isArray(body.tools)) return body;
502
- const allTools = body.tools;
528
+ if (!isPlainObject(body)) return body;
529
+
530
+ // Collect every tool container in the request. Traditional HTTP/SSE requests use top-level
531
+ // `tools`, while Responses Lite may carry the same declarations in `additional_tools` entries.
532
+ const toolGroups: unknown[][] = [];
533
+ if (Array.isArray(body.tools)) toolGroups.push(body.tools);
534
+ if (Array.isArray(body.input)) {
535
+ for (const item of body.input) {
536
+ if (isPlainObject(item) && item.type === "additional_tools" && Array.isArray(item.tools)) {
537
+ toolGroups.push(item.tools);
538
+ }
539
+ }
540
+ }
541
+ if (toolGroups.length === 0) return body;
503
542
 
504
- const conflicting = HOSTED_TOOL_NAME_CONFLICTS.filter(c =>
505
- allTools.some(t => {
543
+ // Detect conflicts across containers because the client namespace and the hosted tool may be
544
+ // declared in different groups while still sharing one platform-validated namespace.
545
+ const conflicting = HOSTED_TOOL_NAME_CONFLICTS.filter(c => toolGroups.some(group =>
546
+ group.some(t => {
506
547
  if (!isPlainObject(t) || typeof t.name !== "string") return false;
507
548
  if (t.type === "namespace") return t.name === c.namePrefix;
508
549
  return t.name === c.namePrefix || t.name.startsWith(`${c.namePrefix}.`);
509
550
  }),
510
- );
551
+ ));
511
552
  if (conflicting.length === 0) return body;
512
553
 
513
- const tools = allTools.filter(t => {
514
- const type = isPlainObject(t) && typeof t.type === "string" ? t.type : undefined;
515
- if (!type) return true;
516
- return !conflicting.some(c => c.hostedType === type);
517
- });
518
- return tools.length === allTools.length ? body : { ...body, tools };
554
+ // Allocate a replacement only when a hosted tool is removed. Preserve malformed entries,
555
+ // unrelated tools, and future tool types so this compatibility rule remains narrowly scoped.
556
+ const stripGroup = (tools: unknown[]): unknown[] => {
557
+ const filtered = tools.filter(t => {
558
+ const type = isPlainObject(t) && typeof t.type === "string" ? t.type : undefined;
559
+ if (!type) return true;
560
+ return !conflicting.some(c => c.hostedType === type);
561
+ });
562
+ return filtered.length === tools.length ? tools : filtered;
563
+ };
564
+
565
+ let changed = false;
566
+ let tools = body.tools;
567
+ if (Array.isArray(body.tools)) {
568
+ tools = stripGroup(body.tools);
569
+ changed ||= tools !== body.tools;
570
+ }
571
+
572
+ let input = body.input;
573
+ if (Array.isArray(body.input)) {
574
+ let nestedChanged = false;
575
+ const mappedInput = body.input.map(item => {
576
+ if (!isPlainObject(item) || item.type !== "additional_tools" || !Array.isArray(item.tools)) {
577
+ return item;
578
+ }
579
+ const nestedTools = stripGroup(item.tools);
580
+ if (nestedTools === item.tools) return item;
581
+ nestedChanged = true;
582
+ return { ...item, tools: nestedTools };
583
+ });
584
+ if (nestedChanged) {
585
+ input = mappedInput;
586
+ changed = true;
587
+ }
588
+ }
589
+
590
+ if (!changed) return body;
591
+ return {
592
+ ...body,
593
+ ...(Array.isArray(body.tools) ? { tools } : {}),
594
+ ...(Array.isArray(body.input) ? { input } : {}),
595
+ };
519
596
  }
520
597
 
521
598
  /**
@@ -674,12 +751,13 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
674
751
  if (parsed._compactionRequest === true && !isCanonicalOpenAiForwardProvider(provider)) {
675
752
  outBody = buildRoutedCompactionBody(outBody);
676
753
  }
754
+ const sanitizedBody = stripSparkCompatibility(stripUnsupportedReasoningParams(stripItemIdsWhenUnstored(stripInvalidItemIds(stripUnsupportedHostedTools(sanitizeReasoningInputContent(scrubOcxCompactionItems(outBody)))))));
677
755
  return {
678
756
  url,
679
757
  method: "POST",
680
758
  headers,
681
759
  body: JSON.stringify(stripDisabledReasoningSummaries(
682
- stripSparkCompatibility(stripUnsupportedReasoningParams(stripItemIdsWhenUnstored(stripInvalidItemIds(stripUnsupportedHostedTools(sanitizeReasoningInputContent(scrubOcxCompactionItems(outBody))))))),
760
+ normalizeConfiguredReasoningSummaryDelivery(sanitizedBody, provider, parsed.modelId),
683
761
  provider,
684
762
  parsed.modelId,
685
763
  )),
package/src/bridge.ts CHANGED
@@ -133,8 +133,18 @@ export function bridgeToResponsesSSE(
133
133
  * from this callback instead of re-parsing the bridged SSE.
134
134
  */
135
135
  onUsage?: (usage: OcxUsage | undefined) => void;
136
+ /**
137
+ * Test seam for the wire/stall beat loop. Production omits this and uses the
138
+ * global timers; injecting here must not change scheduling semantics.
139
+ */
140
+ timers?: {
141
+ setInterval: (handler: () => void, ms: number) => unknown;
142
+ clearInterval: (id: unknown) => void;
143
+ };
136
144
  },
137
145
  ): ReadableStream<Uint8Array> {
146
+ const setBeatInterval = options?.timers?.setInterval ?? ((handler: () => void, ms: number) => setInterval(handler, ms));
147
+ const clearBeatInterval = options?.timers?.clearInterval ?? ((id: unknown) => clearInterval(id as ReturnType<typeof setInterval>));
138
148
  // Freeform/custom tools (apply_patch) carry their body in `input`; the model is given a
139
149
  // function with `{input:string}`, so unwrap it here when relaying back as a custom_tool_call.
140
150
  const freeformInput = (args: string): string => {
@@ -189,17 +199,19 @@ export function bridgeToResponsesSSE(
189
199
  };
190
200
  // RC3 keep-alive: Codex's idle timer is timeout(idle_timeout, stream.next()) over an
191
201
  // eventsource_stream; ANY received event re-arms it, while an unknown type is ignored
192
- // (responses.rs `_ => Ok(None)`). We emit a real, parser-ignored `response.heartbeat` only during
193
- // upstream silence so a stalled routed provider never trips "idle timeout waiting for SSE".
194
- let activity = false;
195
- let beat: ReturnType<typeof setInterval> | undefined;
202
+ // (responses.rs `_ => Ok(None)`). Emit a parser-ignored `response.heartbeat` whenever the
203
+ // *wire* has been silent, even if invisible adapter heartbeats are still flowing (web-search
204
+ // buffering + raw-byte progress). Upstream activity only resets the stall watchdog.
205
+ let upstreamActivity = false;
206
+ let wireActivity = false;
207
+ let beat: unknown;
196
208
  let controller: ReadableStreamDefaultController<Uint8Array>;
197
209
  let emittedFrames = 0;
198
210
  let gated = false;
199
211
  let stepping = false;
200
212
  const emit = (name: string, data: Record<string, unknown>) => {
201
213
  if (closed) return;
202
- activity = true;
214
+ wireActivity = true;
203
215
  try {
204
216
  controller.enqueue(encoder.encode(sseEvent(name, { type: name, sequence_number: seq++, ...data })));
205
217
  emittedFrames++;
@@ -302,8 +314,12 @@ export function bridgeToResponsesSSE(
302
314
  return anns;
303
315
  };
304
316
 
305
- const closeCurrentMessage = () => {
317
+ const closeCurrentMessage = (inferredPhase?: OcxMessagePhase) => {
306
318
  if (!currentMsg) return;
319
+ // Chat Completions has no message-phase field. Keep its live item provisional, then
320
+ // classify it only when the next adapter event proves whether this text led into more
321
+ // work or completed the turn. Explicit adapter phases always outrank this inference.
322
+ const phase = currentMsg.phase ?? inferredPhase;
307
323
  // Bind any pending web-search citations to this assistant message (then they clear).
308
324
  const annotations = takeWebAnnotations();
309
325
  // Finalize the text part (Responses protocol). Without these .done events Codex never
@@ -318,7 +334,7 @@ export function bridgeToResponsesSSE(
318
334
  const item = {
319
335
  type: "message", id: currentMsg.itemId, status: "completed", role: "assistant",
320
336
  content: [{ type: "output_text", text: currentMsg.text, annotations }],
321
- ...(currentMsg.phase ? { phase: currentMsg.phase } : {}),
337
+ ...(phase ? { phase } : {}),
322
338
  };
323
339
  emit("response.output_item.done", { output_index: currentMsg.outputIndex, item });
324
340
  finishedItems.push(item as OutputItem);
@@ -478,7 +494,9 @@ export function bridgeToResponsesSSE(
478
494
  if (next.done) { upstreamDone = true; break; }
479
495
  const event = next.value;
480
496
  let terminalEvent = false;
481
- activity = true;
497
+ // Invisible adapter heartbeats (and buffered web-search progress) count as upstream
498
+ // liveness only — they must not suppress wire keepalives that re-arm Codex idle timers.
499
+ upstreamActivity = true;
482
500
  stallTicks = 0;
483
501
  reportFirstOutput(event);
484
502
  // Compaction turns emit ONLY the synthetic compaction item + response.completed. The
@@ -494,7 +512,7 @@ export function bridgeToResponsesSSE(
494
512
  case "assistant_boundary": {
495
513
  // A guarded continuation starts a fresh assistant output item while keeping the
496
514
  // intermediate, suspicious text in the same Responses turn.
497
- if (currentMsg) closeCurrentMessage();
515
+ if (currentMsg) closeCurrentMessage("commentary");
498
516
  if (currentReasoning) closeCurrentReasoning();
499
517
  if (currentRawReasoning) closeCurrentRawReasoning();
500
518
  flushHiddenRawReasoning();
@@ -507,7 +525,11 @@ export function bridgeToResponsesSSE(
507
525
  if (currentRawReasoning) closeCurrentRawReasoning();
508
526
  flushHiddenRawReasoning();
509
527
  if (currentToolCall) closeCurrentToolCall();
510
- if (currentMsg && currentMsg.phase !== event.phase) closeCurrentMessage();
528
+ // Only flush on an explicit phase change. A later delta that omits `phase` must
529
+ // keep appending to the current message rather than wiping the earlier phase.
530
+ if (currentMsg && event.phase !== undefined && currentMsg.phase !== event.phase) {
531
+ closeCurrentMessage("commentary");
532
+ }
511
533
  if (!currentMsg) {
512
534
  const itemId = `msg_${uuid()}`;
513
535
  const item = {
@@ -531,7 +553,7 @@ export function bridgeToResponsesSSE(
531
553
  }
532
554
  case "thinking_delta": {
533
555
  if (options?.hideThinkingSummary) { hiddenThinkingText += event.thinking; break; }
534
- if (currentMsg) closeCurrentMessage();
556
+ if (currentMsg) closeCurrentMessage("commentary");
535
557
  if (currentRawReasoning) closeCurrentRawReasoning();
536
558
  flushHiddenRawReasoning();
537
559
  if (currentToolCall) closeCurrentToolCall();
@@ -566,7 +588,7 @@ export function bridgeToResponsesSSE(
566
588
  }
567
589
  case "reasoning_raw_delta": {
568
590
  if (options?.hideThinkingSummary) { hiddenRawReasoningText += event.text; break; }
569
- if (currentMsg) closeCurrentMessage();
591
+ if (currentMsg) closeCurrentMessage("commentary");
570
592
  if (currentReasoning) closeCurrentReasoning();
571
593
  if (currentToolCall) closeCurrentToolCall();
572
594
  if (!currentRawReasoning) {
@@ -583,7 +605,7 @@ export function bridgeToResponsesSSE(
583
605
  break;
584
606
  }
585
607
  case "tool_call_start": {
586
- if (currentMsg) closeCurrentMessage();
608
+ if (currentMsg) closeCurrentMessage("commentary");
587
609
  if (currentReasoning) closeCurrentReasoning();
588
610
  if (currentRawReasoning) closeCurrentRawReasoning();
589
611
  flushHiddenRawReasoning();
@@ -638,7 +660,7 @@ export function bridgeToResponsesSSE(
638
660
  // Open the native search cell so Codex shows the "Searching the web" spinner WHILE the
639
661
  // sidecar runs. Close any other open item first, allocate this item's output index, and
640
662
  // hold it open until the matching `web_search_call_end` (or a terminal close).
641
- if (currentMsg) closeCurrentMessage();
663
+ if (currentMsg) closeCurrentMessage("commentary");
642
664
  if (currentReasoning) closeCurrentReasoning();
643
665
  if (currentRawReasoning) closeCurrentRawReasoning();
644
666
  flushHiddenRawReasoning();
@@ -675,7 +697,7 @@ export function bridgeToResponsesSSE(
675
697
  break;
676
698
  }
677
699
  case "done": {
678
- if (currentMsg) closeCurrentMessage();
700
+ if (currentMsg) closeCurrentMessage(event.stopReason ? undefined : "final_answer");
679
701
  if (currentReasoning) closeCurrentReasoning();
680
702
  if (currentRawReasoning) closeCurrentRawReasoning();
681
703
  flushHiddenRawReasoning();
@@ -801,7 +823,7 @@ export function bridgeToResponsesSSE(
801
823
  stepping = false;
802
824
  return;
803
825
  }
804
- if (beat) { clearInterval(beat); beat = undefined; }
826
+ if (beat !== undefined) { clearBeatInterval(beat); beat = undefined; }
805
827
 
806
828
  if (!terminated) {
807
829
  // The adapter generator ended without an explicit done/error event. Mark as incomplete
@@ -840,10 +862,12 @@ export function bridgeToResponsesSSE(
840
862
  // The default ReadableStream strategy has HWM=1. Once one event's frames fill that
841
863
  // queue, pull stepping pauses; no custom FIFO or queuing strategy is layered on top.
842
864
  gated = true;
843
- beat = setInterval(() => {
865
+ beat = setBeatInterval(() => {
844
866
  if (closed || gated) return;
845
- if (activity) { activity = false; stallTicks = 0; return; }
846
- if (++stallTicks >= maxStallTicks) {
867
+ if (upstreamActivity) {
868
+ upstreamActivity = false;
869
+ stallTicks = 0;
870
+ } else if (++stallTicks >= maxStallTicks) {
847
871
  if (currentMsg) closeCurrentMessage();
848
872
  if (currentReasoning) closeCurrentReasoning();
849
873
  if (currentRawReasoning) closeCurrentRawReasoning();
@@ -861,12 +885,17 @@ export function bridgeToResponsesSSE(
861
885
  terminated = true;
862
886
  returnIterator();
863
887
  emitDone();
864
- if (beat) clearInterval(beat);
888
+ if (beat !== undefined) clearBeatInterval(beat);
865
889
  beat = undefined;
866
890
  try { controller.close(); } catch { /* already closed */ }
867
891
  closed = true;
868
892
  return;
869
893
  }
894
+ // Wire silence is independent of upstream adapter heartbeats.
895
+ if (wireActivity) {
896
+ wireActivity = false;
897
+ return;
898
+ }
870
899
  try {
871
900
  controller.enqueue(heartbeatFrame);
872
901
  emittedFrames++;
@@ -887,14 +916,14 @@ export function bridgeToResponsesSSE(
887
916
  cancel() {
888
917
  // Client (Codex) disconnected. Stop emitting and let the caller abort the upstream fetch so a
889
918
  // cancelled turn does not leak the upstream stream or keep draining tokens (RC2).
890
- clientCancelled = true;
891
- closed = true;
892
- if (beat) clearInterval(beat);
893
- onCancel?.();
894
- returnIterator();
895
- },
896
- });
897
- }
919
+ clientCancelled = true;
920
+ closed = true;
921
+ if (beat !== undefined) clearBeatInterval(beat);
922
+ onCancel?.();
923
+ returnIterator();
924
+ },
925
+ });
926
+ }
898
927
 
899
928
  export function buildResponseJSON(
900
929
  events: AdapterEvent[],
@@ -918,6 +947,7 @@ export function buildResponseJSON(
918
947
  let incompleteEvent: Extract<AdapterEvent, { type: "incomplete" }> | undefined;
919
948
  let endTurn: boolean | undefined;
920
949
  let stopReason: string | undefined;
950
+ let cleanDone = false;
921
951
  let compactionText = "";
922
952
 
923
953
  let currentText = "";
@@ -941,8 +971,9 @@ export function buildResponseJSON(
941
971
  try { const o = JSON.parse(args); return o && typeof o === "object" ? o : {}; } catch { return {}; }
942
972
  };
943
973
 
944
- const flushText = () => {
974
+ const flushText = (inferredPhase?: OcxMessagePhase) => {
945
975
  if (!currentText) return;
976
+ const phase = currentTextPhase ?? inferredPhase;
946
977
  const annotations = pendingWebSources.map(s => ({
947
978
  type: "url_citation", url: s.url, ...(s.title ? { title: s.title } : {}), start_index: 0, end_index: 0,
948
979
  }));
@@ -950,7 +981,7 @@ export function buildResponseJSON(
950
981
  output.push({
951
982
  type: "message", id: `msg_${uuid()}`, role: "assistant", status: "completed",
952
983
  content: [{ type: "output_text", text: currentText, annotations }],
953
- ...(currentTextPhase ? { phase: currentTextPhase } : {}),
984
+ ...(phase ? { phase } : {}),
954
985
  });
955
986
  currentText = "";
956
987
  currentTextPhase = undefined;
@@ -1025,13 +1056,15 @@ export function buildResponseJSON(
1025
1056
  for (const e of events) {
1026
1057
  switch (e.type) {
1027
1058
  case "assistant_boundary":
1028
- flushText();
1059
+ flushText("commentary");
1029
1060
  flushSummaryReasoning();
1030
1061
  flushRawReasoning();
1031
1062
  flushToolCall();
1032
1063
  break;
1033
1064
  case "text_delta":
1034
- if (currentText && currentTextPhase !== e.phase) flushText();
1065
+ // Only flush on an explicit phase change. A later delta that omits `phase` must keep
1066
+ // appending under the previously established phase.
1067
+ if (currentText && e.phase !== undefined && currentTextPhase !== e.phase) flushText("commentary");
1035
1068
  if (currentSummaryReasoning) flushSummaryReasoning();
1036
1069
  if (currentRawReasoning) flushRawReasoning();
1037
1070
  if (currentToolCallId) flushToolCall();
@@ -1039,12 +1072,12 @@ export function buildResponseJSON(
1039
1072
  // bridgeToResponsesSSE); it ships only inside the synthetic compaction item below.
1040
1073
  if (options?.compaction) compactionText += e.text;
1041
1074
  else {
1042
- currentTextPhase = e.phase;
1075
+ if (e.phase !== undefined) currentTextPhase = e.phase;
1043
1076
  currentText += e.text;
1044
1077
  }
1045
1078
  break;
1046
1079
  case "thinking_delta":
1047
- if (currentText) flushText();
1080
+ if (currentText) flushText("commentary");
1048
1081
  if (currentRawReasoning) flushRawReasoning();
1049
1082
  if (currentToolCallId) flushToolCall();
1050
1083
  currentSummaryReasoning += e.thinking;
@@ -1059,13 +1092,13 @@ export function buildResponseJSON(
1059
1092
  batchRedacted.push(e.data);
1060
1093
  break;
1061
1094
  case "reasoning_raw_delta":
1062
- if (currentText) flushText();
1095
+ if (currentText) flushText("commentary");
1063
1096
  if (currentSummaryReasoning) flushSummaryReasoning();
1064
1097
  if (currentToolCallId) flushToolCall();
1065
1098
  currentRawReasoning += e.text;
1066
1099
  break;
1067
1100
  case "tool_call_start":
1068
- if (currentText) flushText();
1101
+ if (currentText) flushText("commentary");
1069
1102
  if (currentSummaryReasoning) flushSummaryReasoning();
1070
1103
  if (currentRawReasoning) flushRawReasoning();
1071
1104
  flushToolCall();
@@ -1084,7 +1117,7 @@ export function buildResponseJSON(
1084
1117
  // single finalized item, emitted on `end`. Begin is a no-op here.
1085
1118
  break;
1086
1119
  case "web_search_call_end":
1087
- if (currentText) flushText();
1120
+ if (currentText) flushText("commentary");
1088
1121
  if (currentSummaryReasoning) flushSummaryReasoning();
1089
1122
  if (currentRawReasoning) flushRawReasoning();
1090
1123
  flushToolCall();
@@ -1112,25 +1145,33 @@ export function buildResponseJSON(
1112
1145
  case "done":
1113
1146
  usage = e.usage;
1114
1147
  endTurn = e.endTurn;
1148
+ cleanDone = e.stopReason === undefined;
1115
1149
  if (e.providerState) options?.onProviderState?.(e.providerState);
1116
- if (e.stopReason === "max_tokens") stopReason = "max_tokens";
1150
+ // Match streaming: max_tokens and content_filter both terminate as incomplete.
1151
+ if (e.stopReason === "max_tokens" || e.stopReason === "content_filter") stopReason = e.stopReason;
1117
1152
  break;
1118
1153
  }
1119
1154
  }
1120
- flushText();
1155
+ flushText(cleanDone && !errorEvent && !incompleteEvent ? "final_answer" : undefined);
1121
1156
  flushSummaryReasoning();
1122
1157
  flushRawReasoning();
1123
1158
  flushToolCall();
1124
1159
  // A truncated turn must never be installed as replacement history: emit the
1125
1160
  // compaction item only when the turn actually completed (#422).
1126
- if (options?.compaction && !errorEvent && !incompleteEvent && stopReason !== "max_tokens") {
1161
+ if (
1162
+ options?.compaction
1163
+ && !errorEvent
1164
+ && !incompleteEvent
1165
+ && stopReason !== "max_tokens"
1166
+ && stopReason !== "content_filter"
1167
+ ) {
1127
1168
  output.push({ type: "compaction", id: `cmp_${uuid()}`, encrypted_content: encodeCompactionSummary(compactionText) });
1128
1169
  }
1129
1170
 
1130
1171
  const failure = errorEvent ? adapterFailureFromEvent(errorEvent) : undefined;
1131
1172
  const status = errorEvent
1132
1173
  ? "failed"
1133
- : incompleteEvent || stopReason === "max_tokens"
1174
+ : incompleteEvent || stopReason === "max_tokens" || stopReason === "content_filter"
1134
1175
  ? "incomplete"
1135
1176
  : "completed";
1136
1177
  options?.onUsage?.(incompleteEvent?.usage ?? usage);
@@ -1150,6 +1191,8 @@ export function buildResponseJSON(
1150
1191
  },
1151
1192
  } : stopReason === "max_tokens" ? {
1152
1193
  incomplete_details: { reason: "max_output_tokens" },
1194
+ } : stopReason === "content_filter" ? {
1195
+ incomplete_details: { reason: "content_filter" },
1153
1196
  } : {}),
1154
1197
  usage: responsesUsage(incompleteEvent?.usage ?? usage),
1155
1198
  };
@@ -1159,7 +1202,7 @@ export function formatErrorResponse(
1159
1202
  status: number,
1160
1203
  type: string,
1161
1204
  message: string,
1162
- options?: { code?: string | null },
1205
+ options?: { code?: string | null; retryAfter?: string | null },
1163
1206
  ): Response {
1164
1207
  const error = classifyError(status, type, message);
1165
1208
  if (isCyberPolicyCode(options?.code)) {
@@ -1167,8 +1210,13 @@ export function formatErrorResponse(
1167
1210
  error.type = "invalid_request_error";
1168
1211
  }
1169
1212
  const finalStatus = error.code === CYBER_POLICY_ERROR_CODE ? 400 : status;
1213
+ const headers = new Headers({ "Content-Type": "application/json" });
1214
+ const retryAfter = options?.retryAfter?.trim();
1215
+ if (retryAfter && retryAfter.length > 0 && retryAfter.length <= 128) {
1216
+ headers.set("Retry-After", retryAfter);
1217
+ }
1170
1218
  return new Response(JSON.stringify({ error }), {
1171
1219
  status: finalStatus,
1172
- headers: { "Content-Type": "application/json" },
1220
+ headers,
1173
1221
  });
1174
1222
  }
@@ -0,0 +1,84 @@
1
+ import { homedir } from "node:os";
2
+ import { posix, win32 } from "node:path";
3
+
4
+ /**
5
+ * Claude Desktop's own configLibrary location, ported from the shipped app bundle.
6
+ *
7
+ * Desktop assembles the directory name at runtime, which is why searching the
8
+ * bundle for the literal "Claude-3p" finds almost nothing (app.asar, v1.18286.0):
9
+ *
10
+ * const Bu = "-3p", zW = "Claude", ND = `${zW}${Bu}`;
11
+ * function GE(){
12
+ * if (process.env.CLAUDE_USER_DATA_DIR) return app.getPath("userData");
13
+ * if (process.platform === "win32" && process.env.LOCALAPPDATA) return join(process.env.LOCALAPPDATA, ND);
14
+ * const t = app.getPath("userData");
15
+ * return t.endsWith(Bu) ? t : `${t}${Bu}`;
16
+ * }
17
+ * function mD(){ return join(GE(), "configLibrary") }
18
+ *
19
+ * So `Claude-3p` is Desktop's correct default, not a mistake to "fix" — but the
20
+ * first two branches were missing here, which is what GitHub #539 actually hit.
21
+ *
22
+ * Resolution is a pure function of (env, platform, home) so tests can exercise the
23
+ * win32 branch on any host: stubbing `process.platform` does NOT propagate to
24
+ * `os.platform()` under Bun, so a platform-sniffing implementation would be
25
+ * untestable in this repo.
26
+ */
27
+ const SUFFIX = "-3p";
28
+ const APP_DIR = `Claude${SUFFIX}`;
29
+
30
+ function joinForPlatform(platform: NodeJS.Platform, ...parts: string[]): string {
31
+ return platform === "win32" ? win32.join(...parts) : posix.join(...parts);
32
+ }
33
+
34
+ export interface DesktopPathInputs {
35
+ env: Record<string, string | undefined>;
36
+ platform: NodeJS.Platform;
37
+ home: string;
38
+ }
39
+
40
+ /** Electron `app.getPath("userData")`: per-platform appData + productName ("Claude"). */
41
+ export function resolveElectronUserData(inputs: DesktopPathInputs): string {
42
+ const { env, platform, home } = inputs;
43
+ if (platform === "win32") {
44
+ const appData = env.APPDATA?.trim();
45
+ return appData ? win32.join(appData, "Claude") : win32.join(home, "AppData", "Roaming", "Claude");
46
+ }
47
+ if (platform === "darwin") return posix.join(home, "Library", "Application Support", "Claude");
48
+ const xdg = env.XDG_CONFIG_HOME?.trim();
49
+ return xdg ? posix.join(xdg, "Claude") : posix.join(home, ".config", "Claude");
50
+ }
51
+
52
+ /** Desktop's userData root, mirroring `GE()` branch for branch. */
53
+ export function resolveUserDataDir(inputs: DesktopPathInputs): string {
54
+ const explicit = inputs.env.CLAUDE_USER_DATA_DIR?.trim();
55
+ // Desktop calls app.setPath("userData", CLAUDE_USER_DATA_DIR) at startup, so this
56
+ // path is used verbatim — notably WITHOUT the "-3p" suffix.
57
+ if (explicit) return explicit;
58
+
59
+ if (inputs.platform === "win32") {
60
+ const localAppData = inputs.env.LOCALAPPDATA?.trim();
61
+ if (localAppData) return win32.join(localAppData, APP_DIR);
62
+ }
63
+
64
+ const root = resolveElectronUserData(inputs);
65
+ // Concatenated, not joined: "Claude-3p" is one path component.
66
+ return root.endsWith(SUFFIX) ? root : `${root}${SUFFIX}`;
67
+ }
68
+
69
+ /**
70
+ * The directory opencodex writes its 3P profile into.
71
+ *
72
+ * OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR wins over every other branch and is returned
73
+ * verbatim (no `join`), because tests point it straight at a temp directory.
74
+ */
75
+ export function resolveConfigLibraryDir(inputs: DesktopPathInputs): string {
76
+ const override = inputs.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR?.trim();
77
+ if (override) return override;
78
+ return joinForPlatform(inputs.platform, resolveUserDataDir(inputs), "configLibrary");
79
+ }
80
+
81
+ /** Runtime wrapper. Keep it thin: all branching lives in the pure functions above. */
82
+ export function claudeDesktopConfigLibraryDir(): string {
83
+ return resolveConfigLibraryDir({ env: process.env, platform: process.platform, home: homedir() });
84
+ }
@@ -4,6 +4,7 @@ import { homedir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { atomicWriteFile } from "../config";
6
6
  import type { OcxClaudeDesktopProfile } from "../types";
7
+ import { claudeDesktopConfigLibraryDir, resolveConfigLibraryDir } from "./desktop-3p-paths";
7
8
  import {
8
9
  reconcileDesktopProfile,
9
10
  renderDesktopProfile,
@@ -49,6 +50,33 @@ export interface Desktop3pRoutedModel {
49
50
  */
50
51
  export const DESKTOP_SUPPORTS_1M_THRESHOLD = 1_000_000;
51
52
 
53
+ export interface Desktop3pConfigLibraryOptions {
54
+ env?: NodeJS.ProcessEnv;
55
+ platform?: NodeJS.Platform;
56
+ homeDir?: string;
57
+ }
58
+
59
+ /**
60
+ * Resolve the config library from the same user-data root Claude Desktop uses. Keeping this in one
61
+ * helper prevents the writer and dashboard status probe from agreeing on a path Desktop never reads.
62
+ *
63
+ * The resolution itself lives in `./desktop-3p-paths`, which ports Claude Desktop's own `GE()`
64
+ * branch for branch — including the `-3p` suffix the app appends to its userData root. Dropping
65
+ * that suffix points us at a directory Desktop never reads (GitHub #539).
66
+ */
67
+ export function resolveDesktop3pConfigLibraryPath(
68
+ options: Desktop3pConfigLibraryOptions = {},
69
+ ): string {
70
+ if (options.env === undefined && options.platform === undefined && options.homeDir === undefined) {
71
+ return claudeDesktopConfigLibraryDir();
72
+ }
73
+ return resolveConfigLibraryDir({
74
+ env: options.env ?? process.env,
75
+ platform: options.platform ?? process.platform,
76
+ home: options.homeDir ?? homedir(),
77
+ });
78
+ }
79
+
52
80
  /** CLI arg parsing for `ocx claude desktop` mode flags (mutually exclusive). */
53
81
  export function parseDesktop3pModeArgs(flags: string[]): { mode: Desktop3pConfigMode } | { error: string } {
54
82
  const known = new Map<string, Desktop3pConfigMode>([
@@ -308,8 +336,7 @@ export function writeDesktop3pConfig(
308
336
  mode: Desktop3pConfigMode = "static",
309
337
  profile?: OcxClaudeDesktopProfile,
310
338
  ): { written: boolean; path: string; reason?: string; fingerprint?: string } {
311
- const libraryPath = process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR?.trim()
312
- || join(homedir(), "Library", "Application Support", "Claude-3p", "configLibrary");
339
+ const libraryPath = resolveDesktop3pConfigLibraryPath();
313
340
  const metadataPath = join(libraryPath, "_meta.json");
314
341
  let configPath = libraryPath;
315
342