mixdog 0.9.93 → 0.9.95

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 (186) hide show
  1. package/NOTICE.md +72 -0
  2. package/package.json +16 -6
  3. package/scripts/lib/isolated-root-cleanup.mjs +19 -0
  4. package/scripts/run-suite.mjs +100 -0
  5. package/src/lib/rules-builder.cjs +4 -3
  6. package/src/output-styles/detailed.md +13 -15
  7. package/src/output-styles/extreme-minimal.md +7 -11
  8. package/src/output-styles/minimal.md +4 -7
  9. package/src/output-styles/simple.md +11 -13
  10. package/src/rules/agent/30-explorer.md +22 -16
  11. package/src/rules/lead/01-general.md +1 -2
  12. package/src/rules/lead/lead-brief.md +4 -5
  13. package/src/rules/lead/lead-tool.md +3 -2
  14. package/src/rules/shared/01-tool.md +28 -29
  15. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +2 -2
  16. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +23 -7
  17. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +4 -0
  18. package/src/runtime/agent/orchestrator/agent-trace.mjs +29 -0
  19. package/src/runtime/agent/orchestrator/context/collect.mjs +6 -2
  20. package/src/runtime/agent/orchestrator/mcp/client.mjs +3 -3
  21. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +97 -7
  22. package/src/runtime/agent/orchestrator/providers/anthropic-model-resolve.mjs +11 -4
  23. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +2 -3
  24. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +1 -1
  25. package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +4 -6
  26. package/src/runtime/agent/orchestrator/providers/lib/stream-outcome.mjs +1 -1
  27. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +75 -15
  28. package/src/runtime/agent/orchestrator/providers/openai-codex-metadata.mjs +8 -8
  29. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +7 -8
  30. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +2 -2
  31. package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +14 -16
  32. package/src/runtime/agent/orchestrator/providers/openai-ws-headers.mjs +5 -4
  33. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +10 -10
  34. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +2 -3
  35. package/src/runtime/agent/orchestrator/providers/registry.mjs +51 -4
  36. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +15 -15
  37. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +12 -32
  38. package/src/runtime/agent/orchestrator/session/cache/read-cache.mjs +7 -0
  39. package/src/runtime/agent/orchestrator/session/cache/scoped-cache.mjs +42 -2
  40. package/src/runtime/agent/orchestrator/session/context-compaction-policy.mjs +10 -3
  41. package/src/runtime/agent/orchestrator/session/context-utils.mjs +10 -11
  42. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +1 -0
  43. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +3 -3
  44. package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +0 -14
  45. package/src/runtime/agent/orchestrator/session/loop/stop-hooks.mjs +9 -8
  46. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +7 -4
  47. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +1 -1
  48. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +1 -1
  49. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +40 -13
  50. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +3 -0
  51. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +8 -1
  52. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +5 -9
  53. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +3 -3
  54. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +103 -92
  55. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +98 -3
  56. package/src/runtime/agent/orchestrator/stall-policy.mjs +2 -3
  57. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +3 -3
  58. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +25 -3
  59. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +10 -2
  60. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +5 -5
  61. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +12 -3
  62. package/src/runtime/agent/orchestrator/tools/builtin/grep-formatting.mjs +22 -0
  63. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-context-expander.mjs +491 -0
  64. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-output.mjs +91 -13
  65. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +90 -27
  66. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +20 -1
  67. package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +1 -1
  68. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +4 -4
  69. package/src/runtime/agent/orchestrator/tools/builtin/read-image-resize.mjs +2 -2
  70. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +19 -9
  71. package/src/runtime/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +2 -1
  72. package/src/runtime/agent/orchestrator/tools/builtin/read-special-files.mjs +3 -3
  73. package/src/runtime/agent/orchestrator/tools/builtin/read-streaming.mjs +7 -2
  74. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +32 -4
  75. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +1 -1
  76. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +16 -1
  77. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +546 -23
  78. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +8 -5
  79. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +12 -0
  80. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-spawn.mjs +10 -3
  81. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +35 -5
  82. package/src/runtime/agent/orchestrator/tools/builtin/shell-output.mjs +3 -3
  83. package/src/runtime/agent/orchestrator/tools/builtin/snapshot-store.mjs +98 -0
  84. package/src/runtime/agent/orchestrator/tools/builtin/tool-output-limit.mjs +48 -0
  85. package/src/runtime/agent/orchestrator/tools/builtin.mjs +71 -1
  86. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +7 -3
  87. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +104 -14
  88. package/src/runtime/agent/orchestrator/tools/code-graph/project-root.mjs +47 -2
  89. package/src/runtime/agent/orchestrator/tools/code-graph/search-references.mjs +6 -17
  90. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +2 -4
  91. package/src/runtime/agent/orchestrator/tools/code-graph/trusted-roots.mjs +3 -1
  92. package/src/runtime/agent/orchestrator/tools/env-scrub.mjs +9 -2
  93. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +5 -5
  94. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +1 -1
  95. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +57 -2
  96. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +151 -18
  97. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +5 -1
  98. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +109 -13
  99. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +10 -10
  100. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +4 -5
  101. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +4 -0
  102. package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +1 -1
  103. package/src/runtime/channels/backends/discord.mjs +5 -14
  104. package/src/runtime/channels/backends/telegram.mjs +0 -5
  105. package/src/runtime/channels/lib/inbound-handler.mjs +0 -1
  106. package/src/runtime/channels/lib/output-forwarder.mjs +24 -5
  107. package/src/runtime/channels/lib/scheduler.mjs +1 -1
  108. package/src/runtime/channels/lib/worker-main.mjs +1 -1
  109. package/src/runtime/media/renditions.mjs +21 -2
  110. package/src/runtime/memory/lib/tool-call-handler.mjs +16 -1
  111. package/src/runtime/memory/tool-defs.mjs +6 -6
  112. package/src/runtime/shared/atomic-file.mjs +53 -0
  113. package/src/runtime/shared/background-tasks.mjs +16 -6
  114. package/src/runtime/shared/child-spawn-gate.mjs +50 -26
  115. package/src/runtime/shared/resource-admission.mjs +40 -1
  116. package/src/runtime/shared/task-notification-envelope.mjs +11 -2
  117. package/src/runtime/shared/tool-card-model.mjs +6 -2
  118. package/src/runtime/shared/tool-status.mjs +10 -1
  119. package/src/runtime/shared/tool-surface.mjs +5 -0
  120. package/src/runtime/shared/turn-snapshot.mjs +385 -21
  121. package/src/runtime/shared/turn-worktree-snapshot.mjs +540 -0
  122. package/src/session-runtime/context-status.mjs +9 -3
  123. package/src/session-runtime/lifecycle-api.mjs +35 -5
  124. package/src/session-runtime/mcp-glue.mjs +11 -6
  125. package/src/session-runtime/provider-models.mjs +94 -43
  126. package/src/session-runtime/provider-usage.mjs +26 -2
  127. package/src/session-runtime/runtime-core.mjs +46 -8
  128. package/src/session-runtime/runtime-tunables.mjs +4 -0
  129. package/src/session-runtime/self-update.mjs +33 -4
  130. package/src/session-runtime/session-lifecycle.mjs +2 -0
  131. package/src/session-runtime/session-text.mjs +2 -1
  132. package/src/session-runtime/session-turn-api.mjs +106 -22
  133. package/src/session-runtime/workflow.mjs +11 -4
  134. package/src/standalone/agent-tool.mjs +4 -4
  135. package/src/standalone/backend-daemon.mjs +570 -0
  136. package/src/standalone/channel-daemon-transport.mjs +141 -1
  137. package/src/standalone/channel-worker.mjs +3 -2
  138. package/src/standalone/engine-daemon-client.mjs +894 -0
  139. package/src/standalone/engine-daemon-local-bridge.mjs +20 -0
  140. package/src/standalone/engine-daemon-protocol.mjs +33 -0
  141. package/src/standalone/engine-daemon-service.mjs +864 -0
  142. package/src/standalone/engine-daemon-transport.mjs +603 -0
  143. package/src/standalone/explore-tool.mjs +1 -1
  144. package/src/tui/App.jsx +62 -47
  145. package/src/tui/app/app-format.mjs +4 -2
  146. package/src/tui/app/app-view.jsx +5 -0
  147. package/src/tui/app/channel-pickers.mjs +7 -6
  148. package/src/tui/app/core-memory-picker.mjs +4 -4
  149. package/src/tui/app/extension-pickers.mjs +20 -18
  150. package/src/tui/app/maintenance-pickers.mjs +27 -27
  151. package/src/tui/app/onboarding-steps.mjs +23 -18
  152. package/src/tui/app/prompt-submit.mjs +13 -2
  153. package/src/tui/app/route-pickers.mjs +13 -8
  154. package/src/tui/app/settings-picker.mjs +55 -58
  155. package/src/tui/app/slash-dispatch.mjs +32 -28
  156. package/src/tui/app/transcript-window.mjs +19 -0
  157. package/src/tui/app/usage-context-panels.mjs +22 -7
  158. package/src/tui/app/use-mouse-input.mjs +25 -3
  159. package/src/tui/app/use-prompt-queue-history.mjs +31 -16
  160. package/src/tui/app/use-transcript-scroll.mjs +30 -8
  161. package/src/tui/app/use-transcript-window.mjs +22 -1
  162. package/src/tui/app/use-welcome-prompt-hint.mjs +2 -2
  163. package/src/tui/components/PromptInput.jsx +10 -0
  164. package/src/tui/components/Spinner.jsx +89 -86
  165. package/src/tui/components/TextEntryPanel.jsx +14 -0
  166. package/src/tui/components/ToolExecution.jsx +2 -2
  167. package/src/tui/dist/index.mjs +1537 -9718
  168. package/src/tui/engine/agent-job-feed.mjs +2 -2
  169. package/src/tui/engine/live-share.mjs +97 -4
  170. package/src/tui/engine/session-api-ext.mjs +23 -1
  171. package/src/tui/engine/session-api.mjs +88 -41
  172. package/src/tui/engine/session-flow.mjs +43 -4
  173. package/src/tui/engine/tool-card-results.mjs +6 -0
  174. package/src/tui/engine/turn.mjs +84 -4
  175. package/src/tui/engine-local-session.mjs +1108 -0
  176. package/src/tui/engine.mjs +16 -1057
  177. package/src/tui/index.jsx +47 -2
  178. package/src/tui/markdown/stream-fence.mjs +1 -1
  179. package/src/tui/spinner-meta.mjs +80 -0
  180. package/src/tui/spinner-verbs.mjs +35 -0
  181. package/src/ui/statusline-segments.mjs +43 -10
  182. package/src/ui/statusline.mjs +10 -1
  183. package/scripts/tmp-cdp-errors.mjs +0 -41
  184. package/scripts/tmp-cdp-inspect.mjs +0 -41
  185. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +0 -176
  186. package/src/standalone/channel-daemon.mjs +0 -226
@@ -1,6 +1,6 @@
1
1
  import { createHash } from 'crypto';
2
2
  import { countJsonNextCalls } from './tools/next-call-utils.mjs';
3
- import { splitGrepLinePrefix } from './tools/builtin/grep-formatting.mjs';
3
+ import { parseGrepContextHeader, splitGrepLinePrefix } from './tools/builtin/grep-formatting.mjs';
4
4
  import {
5
5
  appendAgentTrace,
6
6
  normalizeSessionId,
@@ -236,7 +236,27 @@ export function parseGrepCoverage(resultText, toolName, toolArgs, resultKind) {
236
236
  const out = [];
237
237
  const seen = new Set();
238
238
  let sectionPath = null;
239
+ let rawSourceLinesRemaining = 0;
240
+ const addLine = (path, lineNo) => {
241
+ if (!path || !Number.isInteger(lineNo) || lineNo < 1 || out.length >= GREP_COVERAGE_MAX) return;
242
+ const key = `${path}\0${lineNo}`;
243
+ if (seen.has(key)) return;
244
+ seen.add(key);
245
+ out.push({ path: String(path).replace(/\\/g, '/'), line: lineNo });
246
+ };
239
247
  for (const line of String(resultText ?? '').split(/\r?\n/)) {
248
+ if (rawSourceLinesRemaining > 0) {
249
+ rawSourceLinesRemaining--;
250
+ continue;
251
+ }
252
+ const header = parseGrepContextHeader(line);
253
+ if (header) {
254
+ for (let lineNo = header.startLine; lineNo <= header.endLine && out.length < GREP_COVERAGE_MAX; lineNo++) {
255
+ addLine(header.path, lineNo);
256
+ }
257
+ rawSourceLinesRemaining = header.sourceLineCount;
258
+ continue;
259
+ }
240
260
  const section = line.match(/^# grep (.+)$/);
241
261
  if (section) {
242
262
  if (!section[1].startsWith('pattern:')) sectionPath = section[1];
@@ -252,17 +272,13 @@ export function parseGrepCoverage(resultText, toolName, toolArgs, resultKind) {
252
272
  const path = split?.path || (omitted ? toolArgs.path : null) || (sectionOmitted ? sectionPath : null);
253
273
  const lineNo = split?.lineNo || (omitted ? Number(omitted[1]) : null)
254
274
  || (sectionOmitted ? Number(sectionOmitted[1]) : null);
255
- if (!path || !Number.isInteger(lineNo) || lineNo < 1) continue;
256
- const key = `${path}\0${lineNo}`;
257
- if (seen.has(key)) continue;
258
- seen.add(key);
259
- out.push({ path: String(path).replace(/\\/g, '/'), line: lineNo });
275
+ addLine(path, lineNo);
260
276
  if (out.length >= GREP_COVERAGE_MAX) break;
261
277
  }
262
278
  return out.length ? out : null;
263
279
  }
264
280
 
265
- // Codex-style patch failures all arrive as "apply_patch … failed" prose, but
281
+ // Patch failures all arrive as "apply_patch … failed" prose, but
266
282
  // a malformed envelope, a rejected hunk, a preflight veto and a size/lock
267
283
  // guard need different operator responses. Returning null means "nothing
268
284
  // patch-specific here" and lets the generic rules (path/enoent, schema/args,
@@ -368,6 +368,10 @@ function _resolveToolFailurePath() {
368
368
  if (process.env.MIXDOG_TOOL_FAILURE_LOG_DISABLE === '1') return null;
369
369
  if (_toolFailurePath) return _toolFailurePath;
370
370
  const explicit = process.env.MIXDOG_TOOL_FAILURE_LOG_PATH;
371
+ // The repo's own `node --test` suites drive intentional tool failures
372
+ // (patch ordering, arg guards, ...). Without an explicit path those rows
373
+ // land in the user's real failure log and read as production incidents.
374
+ if (!explicit && process.env.NODE_TEST_CONTEXT) return null;
371
375
  // Ship-mode default: skip diagnostic tool-failure log file IO unless
372
376
  // dev/debug, MIXDOG_DIAGNOSTICS, or an explicit path opts back in.
373
377
  if (!explicit && !isDiagnosticIOEnabled()) return null;
@@ -158,6 +158,34 @@ function traceAgentSse({ sessionId, sseParseMs, ttftMs, provider, model, transpo
158
158
  });
159
159
  }
160
160
 
161
+ // Per-turn preflight timing (submit → provider request). The runtime also
162
+ // emits this as a `mixdog:turn-timing` process event for the daemon log, but
163
+ // that sink is process-local and invisible to trace tooling — the row below is
164
+ // what session-bench reads, so TTFT stage regressions stay measurable offline.
165
+ function traceTurnTiming({
166
+ sessionId, status, requestId, ttftMs, endToEndTtftMs,
167
+ queueMs, routeMs, preflightMs, mcpMs, providerMs,
168
+ }) {
169
+ const ms = (value) => (Number.isFinite(Number(value)) ? Math.round(Number(value)) : null);
170
+ const payload = {
171
+ status: status || 'unknown',
172
+ request_id: requestId || null,
173
+ ttft_ms: ms(ttftMs),
174
+ end_to_end_ttft_ms: ms(endToEndTtftMs),
175
+ queue_ms: ms(queueMs),
176
+ route_ms: ms(routeMs),
177
+ preflight_ms: ms(preflightMs),
178
+ mcp_ms: ms(mcpMs),
179
+ provider_ms: ms(providerMs),
180
+ };
181
+ appendAgentTrace({
182
+ sessionId,
183
+ kind: 'turn_timing',
184
+ ...payload,
185
+ payload,
186
+ });
187
+ }
188
+
161
189
  function extractThinkingTokens(rawUsage) {
162
190
  if (!rawUsage || typeof rawUsage !== 'object') return null;
163
191
  const direct = Number(rawUsage.thinking_tokens ?? rawUsage.thinkingTokens);
@@ -294,6 +322,7 @@ export {
294
322
  traceAgentToolFailure,
295
323
  traceAgentCompact,
296
324
  traceAgentUsage,
325
+ traceTurnTiming,
297
326
  resolveTraceUsageInput,
298
327
  grokCacheChainTraceFields,
299
328
  traceAgentCompress,
@@ -374,6 +374,11 @@ function sanitizeMcpInstructionText(text, max = MCP_INSTRUCTION_MAX_CHARS) {
374
374
  /**
375
375
  * Per-server MCP initialize instructions for deferred-pool tools only.
376
376
  * Empty when no instructions or no matching deferred MCP tools → omit block.
377
+ * Emits ONLY the server heading + instruction body: the per-server tool names
378
+ * are deliberately NOT repeated here — every pool tool is already listed once
379
+ * (with its description) in <available-deferred-tools>, and re-listing ~30
380
+ * names per server doubled the MCP share of the BP1 prefix (2026-08-05 audit).
381
+ * Server membership stays evident from the mcp__<server>__ name prefix.
377
382
  */
378
383
  function buildMcpInstructionsManifest(mcpServerInstructions, poolNames) {
379
384
  const map = mcpServerInstructions && typeof mcpServerInstructions === 'object'
@@ -400,8 +405,7 @@ function buildMcpInstructionsManifest(mcpServerInstructions, poolNames) {
400
405
  for (const server of servers) {
401
406
  const safeServer = sanitizeMcpManifestServerName(server);
402
407
  const body = sanitizeMcpInstructionText(map[server]);
403
- const tools = [...toolsByServer.get(server)].sort((a, b) => a.localeCompare(b));
404
- lines.push(`## ${safeServer}`, body, ...tools.map((tool) => `- ${tool}`));
408
+ lines.push(`## ${safeServer}`, body);
405
409
  }
406
410
  lines.push('</mcp-instructions>');
407
411
  return lines.join('\n');
@@ -19,7 +19,7 @@ const AUTO_DETECT_PORTS = {
19
19
  'mixdog-memory': { discovery: 'memory', dir: 'mixdog', file: 'active-instance.json', portField: 'memory_port', endpoint: '/mcp' },
20
20
  };
21
21
  const DEFAULT_MCP_CALL_TIMEOUT_MS = 120000;
22
- // Per-server STARTUP handshake budget (connect + listTools). Codex parity: 10s.
22
+ // Per-server STARTUP handshake budget (connect + listTools): 10s.
23
23
  const DEFAULT_MCP_STARTUP_TIMEOUT_MS = 10000;
24
24
  // --- State ---
25
25
  const servers = new Map();
@@ -264,8 +264,8 @@ function isMcpToolCallTimeoutError(err) {
264
264
  }
265
265
 
266
266
  // MCP per-server STARTUP timeout: bounds the connect + listTools handshake so a
267
- // slow or hung server can't stall boot or the first turn. Default 10s (codex
268
- // parity). Per-server override: startupTimeoutMs / startupTimeoutSec. Global
267
+ // slow or hung server can't stall boot or the first turn. Default 10s.
268
+ // Per-server override: startupTimeoutMs / startupTimeoutSec. Global
269
269
  // env: MIXDOG_MCP_STARTUP_TIMEOUT_MS. A value of 0/off/none/false disables it.
270
270
  export function resolveMcpStartupTimeoutMs(cfg = {}, env = process.env) {
271
271
  const rawMs = cfg?.startupTimeoutMs ?? cfg?.startup_timeout_ms;
@@ -17,6 +17,67 @@ function _sortEffortLevels(levels) {
17
17
  return [...levels].sort((a, b) => rank(a) - rank(b));
18
18
  }
19
19
 
20
+ // Control flags that ride the capabilities.effort map alongside real levels
21
+ // (`{supported:true, low:true, …}`). They are NOT selectable effort levels;
22
+ // letting them through minted a bogus "supported" level that got persisted
23
+ // into the on-disk catalog and offered in the UI.
24
+ const CONTROL_EFFORT_KEYS = new Set(['supported', 'enabled', 'default']);
25
+
26
+ // ── Catalog-first capability index ───────────────────────────────────────
27
+ // The provider catalog already carries what each model advertises
28
+ // (capabilities.effort → reasoningOptions:[{type:'effort',values:[…]}]), so it
29
+ // — not a hardcoded regex ladder — is the source of truth for what we put on
30
+ // the wire. anthropic-model-resolve feeds this whenever the in-memory catalog
31
+ // moves. The regex ladder below stays as the fallback for ids the catalog does
32
+ // not know (offline start, api-key provider, first run before /v1/models).
33
+ const _catalogEffortLevels = new Map();
34
+
35
+ function _catalogKeys(model) {
36
+ const id = normalizeModelId(model);
37
+ if (!id) return [];
38
+ // A dated id and its bare version alias describe the SAME model — index
39
+ // both so `claude-opus-5` and `claude-opus-5-20260101` resolve alike.
40
+ const undated = id.replace(/-\d{8}$/, '');
41
+ return undated !== id ? [id, undated] : [id];
42
+ }
43
+
44
+ /**
45
+ * Replace the catalog-derived capability index. Records without a
46
+ * `reasoningOptions` array are treated as UNKNOWN (skipped, so the regex
47
+ * fallback still applies) rather than as "no effort" — offline/static fallback
48
+ * lists carry no capability data and must not disable effort.
49
+ */
50
+ export function setModelEffortCapabilities(models) {
51
+ _catalogEffortLevels.clear();
52
+ if (!Array.isArray(models)) return;
53
+ for (const model of models) {
54
+ if (!model?.id || !Array.isArray(model.reasoningOptions)) continue;
55
+ const option = model.reasoningOptions.find(
56
+ (entry) => String(entry?.type || '').trim().toLowerCase() === 'effort',
57
+ );
58
+ const levels = new Set(
59
+ (Array.isArray(option?.values) ? option.values : [])
60
+ .map((value) => String(value || '').trim().toLowerCase())
61
+ .filter((value) => value && !CONTROL_EFFORT_KEYS.has(value)),
62
+ );
63
+ for (const key of _catalogKeys(model.id)) {
64
+ const existing = _catalogEffortLevels.get(key);
65
+ // A bare alias shared by several records keeps the richer entry.
66
+ if (existing?.size && levels.size === 0) continue;
67
+ _catalogEffortLevels.set(key, levels);
68
+ }
69
+ }
70
+ }
71
+
72
+ /** Advertised levels for `model`, or null when the catalog has no record. */
73
+ function _catalogLevels(model) {
74
+ for (const key of _catalogKeys(model)) {
75
+ const levels = _catalogEffortLevels.get(key);
76
+ if (levels) return levels;
77
+ }
78
+ return null;
79
+ }
80
+
20
81
  export const EFFORT_BETA_HEADER = 'effort-2025-11-24';
21
82
 
22
83
  export const LEGACY_EFFORT_BUDGET = Object.freeze({
@@ -40,7 +101,10 @@ function parseClaudeVersion(model) {
40
101
  if (triple) {
41
102
  return { family: triple[1], major: Number(triple[2]), minor: Number(triple[3]) };
42
103
  }
43
- const pair = m.match(/^claude-(sonnet|fable)-(\d+)(?:$|[-@])/);
104
+ // Bare version ids carry family + major only (claude-opus-5). opus/haiku
105
+ // were missing here, so `claude-opus-5` parsed to null and fell through
106
+ // every capability check as an unknown shape.
107
+ const pair = m.match(/^claude-(sonnet|fable|opus|haiku)-(\d+)(?:$|[-@])/);
44
108
  if (pair) {
45
109
  return { family: pair[1], major: Number(pair[2]), minor: null };
46
110
  }
@@ -64,6 +128,9 @@ function isLegacyAnthropicReasoningModel(model) {
64
128
  if (parsed.family === 'sonnet' || parsed.family === 'opus') {
65
129
  if (parsed.major < 4) return true;
66
130
  if (parsed.major === 4 && parsed.minor !== null && parsed.minor < 6) return true;
131
+ // Bare major, no minor (claude-opus-4 / claude-sonnet-4): adaptive
132
+ // effort only ships from 5 up, so 4.x aliases stay manual-thinking.
133
+ if (parsed.minor === null && parsed.major < 5) return true;
67
134
  return false;
68
135
  }
69
136
  return false;
@@ -72,13 +139,22 @@ function isLegacyAnthropicReasoningModel(model) {
72
139
  // @[MODEL LAUNCH]: extend allowlist when new Claude models ship with effort support.
73
140
  export function modelSupportsEffort(model) {
74
141
  if (isEnvTruthy(process.env.MIXDOG_ANTHROPIC_ALWAYS_ENABLE_EFFORT)) return true;
142
+ const advertised = _catalogLevels(model);
143
+ if (advertised) return advertised.size > 0;
144
+ return _regexSupportsEffort(model);
145
+ }
146
+
147
+ // Fallback ladder for ids the catalog does not carry. Also used by
148
+ // effortValuesForModel, which BUILDS the catalog records and therefore must
149
+ // never consult the index it feeds.
150
+ function _regexSupportsEffort(model) {
75
151
  const m = normalizeModelId(model);
76
152
  if (!m.includes('claude')) return false;
77
153
  if (isLegacyAnthropicReasoningModel(model)) return false;
78
154
  if (m.includes('opus-4-6') || m.includes('sonnet-4-6')) return true;
79
155
  if (m.includes('sonnet-5') || m.includes('fable-5')) return true;
80
156
  if (/^claude-opus-4-(6|7|8)(?:$|[-@])/.test(m)) return true;
81
- if (/^claude-opus-5-/.test(m)) return true;
157
+ if (/^claude-opus-5(?:$|[-@])/.test(m)) return true;
82
158
  // Fallthrough for not-yet-enumerated modern models: only grant effort when
83
159
  // parseClaudeVersion resolves a family with major>=4 (and not a manual-
84
160
  // thinking legacy already excluded above). A bare `startsWith('claude-')`
@@ -98,6 +174,12 @@ export function modelSupportsEffort(model) {
98
174
  // supported by: Fable 5, Mythos 5, Opus 4.8, Opus 4.7, Sonnet 5.
99
175
  // NOT Opus 4.6 / Sonnet 4.6 (those support max but not xhigh).
100
176
  export function modelSupportsXhighEffort(model) {
177
+ const advertised = _catalogLevels(model);
178
+ if (advertised) return advertised.has('xhigh');
179
+ return _regexSupportsXhighEffort(model);
180
+ }
181
+
182
+ function _regexSupportsXhighEffort(model) {
101
183
  const m = normalizeModelId(model);
102
184
  if (/^claude-opus-4-(7|8)(?:$|[-@])/.test(m)) return true;
103
185
  if (/^claude-opus-5(?:$|[-@])/.test(m)) return true;
@@ -111,7 +193,14 @@ export function modelSupportsXhighEffort(model) {
111
193
  // @[MODEL LAUNCH]: extend when new Opus models support max effort.
112
194
  // Max list = xhigh list PLUS Opus 4.6, Sonnet 4.6, Opus 4.5.
113
195
  export function modelSupportsMaxEffort(model) {
114
- if (modelSupportsXhighEffort(model)) return true;
196
+ const advertised = _catalogLevels(model);
197
+ // xhigh support implies max support (kept from the ladder below).
198
+ if (advertised) return advertised.has('max') || advertised.has('xhigh');
199
+ return _regexSupportsMaxEffort(model);
200
+ }
201
+
202
+ function _regexSupportsMaxEffort(model) {
203
+ if (_regexSupportsXhighEffort(model)) return true;
115
204
  const m = normalizeModelId(model);
116
205
  if (/^claude-opus-4-(5|6)(?:$|[-@])/.test(m)) return true;
117
206
  if (/^claude-sonnet-4-6(?:$|[-@])/.test(m)) return true;
@@ -209,7 +298,7 @@ export function applyAnthropicEffortToBody(
209
298
  // Set unconditionally (independent of `normalized`) so effort-capable
210
299
  // turns always carry adaptive thinking + round-trip signatures.
211
300
  // MIXDOG_ANTHROPIC_THINKING_DISPLAY=omitted (operator/bench knob):
212
- // CC-parity mode — no thinking blocks come back, so nothing is
301
+ // thinking blocks are omitted entirely, so nothing is
213
302
  // replayed into later requests (saves the 1h cache-write + re-read on
214
303
  // accumulated thinking) at the cost of losing visible reasoning and
215
304
  // cross-iteration thinking continuity. Default stays summarized.
@@ -254,7 +343,8 @@ export function effortValuesForModel(capabilities, modelId) {
254
343
  // advertises (xhigh, max, or anything future), no hardcoded allowlist.
255
344
  if (effort !== true && typeof effort === 'object') {
256
345
  const advertised = Object.keys(effort).filter(
257
- (level) => effort[level] === true || effort[level]?.supported === true,
346
+ (level) => !CONTROL_EFFORT_KEYS.has(String(level).trim().toLowerCase())
347
+ && (effort[level] === true || effort[level]?.supported === true),
258
348
  );
259
349
  if (advertised.length) return _sortEffortLevels(advertised);
260
350
  // Object with no per-level flags: fall through to the boolean/supported
@@ -265,10 +355,10 @@ export function effortValuesForModel(capabilities, modelId) {
265
355
  // the model supports effort but doesn't enumerate levels, so derive the
266
356
  // set from the known level ladder, gated by the top-tier model check.
267
357
  let levels = [...EFFORT_LEVELS];
268
- if (!modelSupportsMaxEffort(modelId)) {
358
+ if (!_regexSupportsMaxEffort(modelId)) {
269
359
  levels = levels.filter((level) => level !== 'max');
270
360
  }
271
- if (!modelSupportsXhighEffort(modelId)) {
361
+ if (!_regexSupportsXhighEffort(modelId)) {
272
362
  levels = levels.filter((level) => level !== 'xhigh');
273
363
  }
274
364
  return levels;
@@ -8,20 +8,22 @@
8
8
  import { enrichModels } from './model-catalog.mjs';
9
9
  import { sanitizeModelList } from './model-list-sanitize.mjs';
10
10
  import { makeModelCache } from './model-cache.mjs';
11
- import { effortValuesForModel } from './anthropic-effort.mjs';
11
+ import { effortValuesForModel, setModelEffortCapabilities } from './anthropic-effort.mjs';
12
12
 
13
13
  // Disk-backed cache so repeated process starts (cron, tool calls) don't
14
14
  // hammer /v1/models. 24h TTL matches the upstream client cadence.
15
15
  const MODEL_CACHE_TTL_MS = 24 * 60 * 60_000;
16
16
  // Bump when the on-disk cache shape changes so stale-shape entries are
17
17
  // discarded instead of misread.
18
- const ANTHROPIC_MODEL_CACHE_SCHEMA_VERSION = 1;
18
+ // v2: effort level lists no longer carry the `supported` control flag as a
19
+ // selectable level, so v1 caches are discarded rather than replayed.
20
+ const ANTHROPIC_MODEL_CACHE_SCHEMA_VERSION = 2;
19
21
 
20
22
  const _modelCache = makeModelCache({
21
23
  fileName: 'anthropic-oauth-models.json',
22
24
  ttlMs: MODEL_CACHE_TTL_MS,
23
25
  version: ANTHROPIC_MODEL_CACHE_SCHEMA_VERSION,
24
- onSave: (m) => { _inMemoryCatalog = Array.isArray(m) ? m.slice() : null; },
26
+ onSave: (m) => { _setInMemoryCatalog(m); },
25
27
  });
26
28
 
27
29
  // Async wrappers so callers can keep awaiting; the shared cache CRUD is sync.
@@ -42,6 +44,9 @@ let _inMemoryCatalog = null;
42
44
  // listModels() warm path, so expose a setter instead of the raw binding.
43
45
  export function _setInMemoryCatalog(models) {
44
46
  _inMemoryCatalog = Array.isArray(models) ? models.slice() : null;
47
+ // The request builder resolves effort capability from the catalog, so the
48
+ // capability index moves with the mirror — one write, one source of truth.
49
+ setModelEffortCapabilities(_inMemoryCatalog || []);
45
50
  }
46
51
 
47
52
  export function _catalogHas(id) {
@@ -185,7 +190,9 @@ export function _catalogOutputTokens(model) {
185
190
  try {
186
191
  if (!Array.isArray(_inMemoryCatalog)) {
187
192
  const cached = _modelCache.loadSync();
188
- if (Array.isArray(cached)) _inMemoryCatalog = cached.slice();
193
+ // Route the lazy warm through the setter: a direct assignment left
194
+ // the effort-capability index empty even though the mirror was warm.
195
+ if (Array.isArray(cached)) _setInMemoryCatalog(cached);
189
196
  }
190
197
  if (!Array.isArray(_inMemoryCatalog)) return null;
191
198
  const entry = _inMemoryCatalog.find(m => m?.id === model);
@@ -1094,7 +1094,7 @@ export class AnthropicOAuthProvider {
1094
1094
  continue;
1095
1095
  }
1096
1096
  const classifier = _classifyMidstreamError(err, midState);
1097
- // CC-parity stall recovery (2026-08-03 v3 postmortem): a
1097
+ // Stall recovery (2026-08-03 v3 postmortem): a
1098
1098
  // stalled stream that exposed NOTHING (no text/thinking
1099
1099
  // relayed, no tool emitted) is re-issued NON-STREAMING instead
1100
1100
  // of retrying the same streaming shape. Effort-mode models can
@@ -1103,8 +1103,7 @@ export class AnthropicOAuthProvider {
1103
1103
  // generation into the same timer (observed live: deterministic
1104
1104
  // 4×~138s beheading, ~552s per turn), while the non-streaming
1105
1105
  // transport simply waits for the full body (bounded by
1106
- // PROVIDER_NONSTREAM_TOTAL_TIMEOUT_MS). Claude Code does
1107
- // exactly this on its watchdog aborts. Replay is trivially
1106
+ // PROVIDER_NONSTREAM_TOTAL_TIMEOUT_MS). Replay is trivially
1108
1107
  // safe here — nothing was relayed or dispatched.
1109
1108
  if (classifier === 'stream_stalled'
1110
1109
  && _outcome?.replayUnsafe !== true
@@ -604,7 +604,7 @@ export class AnthropicProvider {
604
604
  continue;
605
605
  }
606
606
  const classifier = _classifyMidstreamError(err, midState);
607
- // CC-parity stall recovery (ported from anthropic-oauth,
607
+ // Stall recovery (shared with anthropic-oauth,
608
608
  // 2026-08-03): a stalled stream that exposed NOTHING is
609
609
  // re-issued non-streaming instead of retrying the same
610
610
  // streaming shape into the same idle window. Replay is
@@ -2,13 +2,11 @@
2
2
  * codex-client-meta.mjs — codex client identity headers for the OpenAI OAuth
3
3
  * transports.
4
4
  *
5
- * codex-rs sends two client-identity headers on EVERY request including the
6
- * WS handshake:
5
+ * The reference client sends two client-identity headers on EVERY request,
6
+ * including the WS handshake:
7
7
  * - `User-Agent: codex_cli_rs/<version> (<os> <ver>; <arch>) <terminal>`
8
- * (login/src/auth/default_client.rs get_codex_user_agent + default_headers)
9
8
  * - `version: <CARGO_PKG_VERSION>` via the built-in provider http_headers
10
- * (model-provider-info/src/lib.rs:339-343), merged into the WS handshake by
11
- * merge_request_headers (codex-api/src/endpoint/responses_websocket.rs).
9
+ * merged into the WS handshake.
12
10
  * The backend uses these for client gating (model catalog visibility measured
13
11
  * 2026-07-03) and plausibly for x-codex-turn-state issuance / sticky
14
12
  * cache-node routing, so mixdog mirrors both.
@@ -17,7 +15,7 @@ import os from 'node:os';
17
15
 
18
16
  // Offline fallback only; live value refreshes from npm (24h TTL, in-process).
19
17
  // The backend gates model exposure AND per-request model access on the client
20
- // version (gpt-5.6-* require >= 0.144.0 per codex models-manager/models.json,
18
+ // version (gpt-5.6-* require >= 0.144.0 per the published model catalog,
21
19
  // verified 2026-07-09), so keep this at the current release when bumping.
22
20
  const CODEX_CLIENT_VERSION_FLOOR = '0.144.1';
23
21
  const VERSION_TTL_MS = 24 * 60 * 60_000;
@@ -50,7 +50,7 @@
50
50
  * || toolCallsComplete > 0 || toolCallsDispatched > 0
51
51
  * sideEffectDispatched = toolCallsDispatched > 0
52
52
  * replayUnsafe = the ONE architecture-specific deny MixDog adds on top
53
- * of the Codex retry rules, because it streams UI text
53
+ * of the standard retry rules, because it streams UI text
54
54
  * and dispatches tools eagerly:
55
55
  * visibleOutput || sideEffectDispatched
56
56
  * || dispatchAmbiguous || unsafe marker
@@ -18,6 +18,10 @@ const FETCH_TIMEOUT_MS = 4500;
18
18
  const WARN_TTL_MS = 5 * 60_000;
19
19
  const CODEX_RESET_CREDITS_URL = 'https://chatgpt.com/backend-api/wham/rate-limit-reset-credits';
20
20
  const CODEX_RESET_CONSUME_URL = `${CODEX_RESET_CREDITS_URL}/consume`;
21
+ // Redeeming a reset credit is an explicit user action, not a poll: it gets the
22
+ // generous budget the orca client uses (REDEEM_BACKEND_TIMEOUT_MS) so a slow
23
+ // backend cannot abort a request the server is already applying.
24
+ const CODEX_REDEEM_TIMEOUT_MS = 30_000;
21
25
 
22
26
  const memoryCache = new Map();
23
27
  const inflight = new Map();
@@ -99,6 +103,38 @@ try {
99
103
  // Embedded runtimes may not expose process lifecycle hooks.
100
104
  }
101
105
 
106
+ /** Drops every cached usage snapshot of one provider (memory, queued disk
107
+ * writes and the persisted routes). A mutation that changes quota state
108
+ * server-side — redeeming a Codex reset credit — must not keep serving the
109
+ * pre-mutation meters from a 60s/10min cache. */
110
+ export function invalidateOAuthUsageSnapshots(provider) {
111
+ const providerOnly = String(provider || '').toLowerCase();
112
+ if (!providerOnly) return;
113
+ const routePrefix = `${providerOnly}\u0001`;
114
+ const owned = (key) => key === providerOnly || String(key).startsWith(routePrefix);
115
+ for (const key of [...memoryCache.keys()]) {
116
+ if (owned(key)) memoryCache.delete(key);
117
+ }
118
+ for (const key of [...pendingDiskSnapshots.keys()]) {
119
+ if (owned(key)) pendingDiskSnapshots.delete(key);
120
+ }
121
+ try {
122
+ updateJsonAtomicSync(cachePath(), (curRaw) => {
123
+ const cur = curRaw && typeof curRaw === 'object' ? curRaw : {};
124
+ const routes = cur.routes && typeof cur.routes === 'object' ? cur.routes : {};
125
+ return {
126
+ version: 1,
127
+ updatedAt: Date.now(),
128
+ routes: Object.fromEntries(
129
+ Object.entries(routes).filter(([key]) => !owned(key)),
130
+ ),
131
+ };
132
+ }, { compact: true, fsync: false, fsyncDir: false });
133
+ } catch {
134
+ // Usage display must never break the reset path.
135
+ }
136
+ }
137
+
102
138
  function isContentfulSnapshot(snapshot) {
103
139
  return !!snapshot
104
140
  && typeof snapshot === 'object'
@@ -253,11 +289,15 @@ function normalizeOpenAICodexResetCredits(data, accountId = '') {
253
289
  .filter((value) => Number.isFinite(value) && value > 0);
254
290
  const nextExpiresAt = resetAtMs(data.next_expires_at ?? data.nextExpiresAt)
255
291
  || (expiryCandidates.length ? Math.min(...expiryCandidates) : null);
292
+ // Identity of the OFFER, not of one payload shape: the detail endpoint and
293
+ // the counts embedded in /wham/usage describe the same credits with
294
+ // different fields, so hashing the raw rows made the same offer produce two
295
+ // revisions — and the desktop scopes its durable idempotency key by
296
+ // revision. Count + soonest expiry is what a user is offered.
256
297
  const offerRevision = `v1:${createHash('sha256').update(JSON.stringify({
257
298
  accountId,
258
299
  availableCount,
259
300
  nextExpiresAt,
260
- credits,
261
301
  })).digest('hex')}`;
262
302
  return {
263
303
  availableCount,
@@ -290,6 +330,32 @@ function codexResetOutcome(code) {
290
330
  throw new Error(`Unknown Codex reset outcome: ${cleanString(code) || 'missing'}`);
291
331
  }
292
332
 
333
+ async function postOpenAICodexResetConsume(auth, idempotencyKey) {
334
+ return await fetch(CODEX_RESET_CONSUME_URL, {
335
+ ...fetchOptions({
336
+ ...codexHeaders(auth),
337
+ 'Content-Type': 'application/json',
338
+ }, CODEX_REDEEM_TIMEOUT_MS),
339
+ method: 'POST',
340
+ body: JSON.stringify({ redeem_request_id: idempotencyKey }),
341
+ });
342
+ }
343
+
344
+ async function redeemOpenAICodexResetCredit(auth, idempotencyKey) {
345
+ // A transport failure (abort, dropped socket) leaves the outcome unknown
346
+ // while the credit may already be spent. redeem_request_id makes the request
347
+ // idempotent, so ONE replay turns that unknown into the server's real answer
348
+ // instead of reporting "could not be confirmed" over a consumed credit.
349
+ let response;
350
+ try {
351
+ response = await postOpenAICodexResetConsume(auth, idempotencyKey);
352
+ } catch {
353
+ response = await postOpenAICodexResetConsume(auth, idempotencyKey);
354
+ }
355
+ if (!response.ok) throw new Error(`Codex reset failed: HTTP ${response.status}`);
356
+ return codexResetOutcome((await response.json())?.code);
357
+ }
358
+
293
359
  export async function consumeOpenAICodexResetCredit(providerObj, options = {}) {
294
360
  const expectedOfferRevision = cleanString(options?.expectedOfferRevision);
295
361
  const idempotencyKey = cleanString(options?.idempotencyKey);
@@ -301,20 +367,14 @@ export async function consumeOpenAICodexResetCredit(providerObj, options = {}) {
301
367
  }
302
368
  const auth = await resolveOpenAICodexAuth(providerObj);
303
369
  if (!auth) throw new Error('Codex is not signed in');
304
- const current = await fetchOpenAICodexResetCreditsWithAuth(auth);
305
- if (!current || current.availableCount < 1 || current.offerRevision !== expectedOfferRevision) {
306
- return { status: 'offerChanged', resetCredits: current };
307
- }
308
- const response = await fetch(CODEX_RESET_CONSUME_URL, {
309
- ...fetchOptions({
310
- ...codexHeaders(auth),
311
- 'Content-Type': 'application/json',
312
- }, 15_000),
313
- method: 'POST',
314
- body: JSON.stringify({ redeem_request_id: idempotencyKey }),
315
- });
316
- if (!response.ok) throw new Error(`Codex reset failed: HTTP ${response.status}`);
317
- const outcome = codexResetOutcome((await response.json())?.code);
370
+ // The SERVER decides the outcome (orca parity): redeem_request_id makes the
371
+ // call idempotent and `already_redeemed`/`no_credit` are real answers. The
372
+ // old client-side offer gate ran before every attempt, so retrying an
373
+ // unconfirmed redeem — whose credit was already spent, hence a changed
374
+ // revision — could only ever report "offer changed" and never the truth.
375
+ const outcome = await redeemOpenAICodexResetCredit(auth, idempotencyKey);
376
+ // Quota meters just changed server-side; cached snapshots are now wrong.
377
+ invalidateOAuthUsageSnapshots('openai-oauth');
318
378
  const resetCredits = await fetchOpenAICodexResetCreditsWithAuth(auth).catch(() => null);
319
379
  return { outcome, resetCredits };
320
380
  }
@@ -35,8 +35,8 @@ function _codexInstallationId(sendOpts) {
35
35
  || `mixdog-${_hashText(`${process.env.USERPROFILE || process.env.HOME || ''}:${process.cwd()}`, 32)}`;
36
36
  }
37
37
 
38
- // The identity block codex rebuilds per request (responses_metadata.rs
39
- // client_metadata()): never cached on the pooled socket, or a later turn would
38
+ // The identity block is rebuilt per request: never cached on the pooled
39
+ // socket, or a later turn would
40
40
  // replay the first turn's identity.
41
41
  function _codexMetadataBase(entry, { poolKey, cacheKey, sendOpts, handshake = false } = {}) {
42
42
  const sessionId = _cleanMetaString(sendOpts?.codexSessionId || sendOpts?.session?.codexSessionId || poolKey || cacheKey)
@@ -49,8 +49,8 @@ function _codexMetadataBase(entry, { poolKey, cacheKey, sendOpts, handshake = fa
49
49
  : _sessionStartedAtUnixMs(sessionId);
50
50
  const requestKind = _codexRequestKind(sendOpts, sessionId);
51
51
  const wireParity = process.env.MIXDOG_OAI_CODEX_WIRE_PARITY === '1';
52
- // codex opens the WS with a prewarm (empty turn_id) BEFORE the real turn
53
- // (client.rs). Under wire parity the handshake IS that prewarm, so its
52
+ // The reference client opens the WS with a prewarm (empty turn_id) BEFORE
53
+ // the real turn. Under wire parity the handshake IS that prewarm, so its
54
54
  // turn_id empties and its request_kind becomes 'prewarm' instead of
55
55
  // presenting the handshake as a live turn. Parity off is unchanged.
56
56
  const isPrewarm = requestKind === 'prewarm' || handshake === true;
@@ -66,7 +66,7 @@ function _codexMetadataBase(entry, { poolKey, cacheKey, sendOpts, handshake = fa
66
66
  turn_id: turnId,
67
67
  window_id: windowId,
68
68
  request_kind: effectiveRequestKind,
69
- // Richer codex turn-metadata (responses_metadata.rs:264-280). A/B
69
+ // Richer turn-metadata. A/B
70
70
  // 2026-07-04 showed no effect, so it stays behind a knob for future
71
71
  // probes; wire parity implies it.
72
72
  ...((process.env.MIXDOG_OAI_TURN_METADATA_RICH === '1' || wireParity) ? {
@@ -98,8 +98,8 @@ export function _metadataTrace(metadata) {
98
98
  };
99
99
  }
100
100
 
101
- // Handshake projection of the same identity. codex attaches these on every
102
- // request (client.rs:582-584, responses_metadata.rs:227-252); A/B 2026-07-04
101
+ // Handshake projection of the same identity. These ride on every
102
+ // request; A/B 2026-07-04
103
103
  // showed the turn-metadata blob alone lifts prefix-cache hits, so it is ON by
104
104
  // default. MIXDOG_OAI_TURN_METADATA overrides:
105
105
  // unset|1|turn-metadata : window-id + turn-metadata + installation-id
@@ -141,7 +141,7 @@ export function _withCodexWsClientMetadata(frame, entry, enabled, context = {})
141
141
  'x-codex-ws-stream-request-start-ms': String(Date.now()),
142
142
  };
143
143
  if (entry && typeof entry === 'object') {
144
- // codex scopes x-codex-turn-state to ONE turn (client.rs:263-279) while
144
+ // x-codex-turn-state is scoped to ONE turn while
145
145
  // pooled sockets span turns: attribute a captured token to the FIRST
146
146
  // turn that observes it, then drop it once turn_id moves on. An empty
147
147
  // turn_id (parity prewarm) is a valid owner, so the check is against