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.
- package/NOTICE.md +72 -0
- package/package.json +16 -6
- package/scripts/lib/isolated-root-cleanup.mjs +19 -0
- package/scripts/run-suite.mjs +100 -0
- package/src/lib/rules-builder.cjs +4 -3
- package/src/output-styles/detailed.md +13 -15
- package/src/output-styles/extreme-minimal.md +7 -11
- package/src/output-styles/minimal.md +4 -7
- package/src/output-styles/simple.md +11 -13
- package/src/rules/agent/30-explorer.md +22 -16
- package/src/rules/lead/01-general.md +1 -2
- package/src/rules/lead/lead-brief.md +4 -5
- package/src/rules/lead/lead-tool.md +3 -2
- package/src/rules/shared/01-tool.md +28 -29
- package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +2 -2
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +23 -7
- package/src/runtime/agent/orchestrator/agent-trace-io.mjs +4 -0
- package/src/runtime/agent/orchestrator/agent-trace.mjs +29 -0
- package/src/runtime/agent/orchestrator/context/collect.mjs +6 -2
- package/src/runtime/agent/orchestrator/mcp/client.mjs +3 -3
- package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +97 -7
- package/src/runtime/agent/orchestrator/providers/anthropic-model-resolve.mjs +11 -4
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +2 -3
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +4 -6
- package/src/runtime/agent/orchestrator/providers/lib/stream-outcome.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +75 -15
- package/src/runtime/agent/orchestrator/providers/openai-codex-metadata.mjs +8 -8
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +7 -8
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +2 -2
- package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +14 -16
- package/src/runtime/agent/orchestrator/providers/openai-ws-headers.mjs +5 -4
- package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +10 -10
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +2 -3
- package/src/runtime/agent/orchestrator/providers/registry.mjs +51 -4
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +15 -15
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +12 -32
- package/src/runtime/agent/orchestrator/session/cache/read-cache.mjs +7 -0
- package/src/runtime/agent/orchestrator/session/cache/scoped-cache.mjs +42 -2
- package/src/runtime/agent/orchestrator/session/context-compaction-policy.mjs +10 -3
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +10 -11
- package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +1 -0
- package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +3 -3
- package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +0 -14
- package/src/runtime/agent/orchestrator/session/loop/stop-hooks.mjs +9 -8
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +7 -4
- package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +40 -13
- package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +3 -0
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +8 -1
- package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +5 -9
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +3 -3
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +103 -92
- package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +98 -3
- package/src/runtime/agent/orchestrator/stall-policy.mjs +2 -3
- package/src/runtime/agent/orchestrator/tools/bash-session.mjs +3 -3
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +25 -3
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +10 -2
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +5 -5
- package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +12 -3
- package/src/runtime/agent/orchestrator/tools/builtin/grep-formatting.mjs +22 -0
- package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-context-expander.mjs +491 -0
- package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-output.mjs +91 -13
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +90 -27
- package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +20 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +4 -4
- package/src/runtime/agent/orchestrator/tools/builtin/read-image-resize.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +19 -9
- package/src/runtime/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +2 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-special-files.mjs +3 -3
- package/src/runtime/agent/orchestrator/tools/builtin/read-streaming.mjs +7 -2
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +32 -4
- package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +16 -1
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +546 -23
- package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +8 -5
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +12 -0
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-spawn.mjs +10 -3
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +35 -5
- package/src/runtime/agent/orchestrator/tools/builtin/shell-output.mjs +3 -3
- package/src/runtime/agent/orchestrator/tools/builtin/snapshot-store.mjs +98 -0
- package/src/runtime/agent/orchestrator/tools/builtin/tool-output-limit.mjs +48 -0
- package/src/runtime/agent/orchestrator/tools/builtin.mjs +71 -1
- package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +7 -3
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +104 -14
- package/src/runtime/agent/orchestrator/tools/code-graph/project-root.mjs +47 -2
- package/src/runtime/agent/orchestrator/tools/code-graph/search-references.mjs +6 -17
- package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +2 -4
- package/src/runtime/agent/orchestrator/tools/code-graph/trusted-roots.mjs +3 -1
- package/src/runtime/agent/orchestrator/tools/env-scrub.mjs +9 -2
- package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +5 -5
- package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +57 -2
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +151 -18
- package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +5 -1
- package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +109 -13
- package/src/runtime/agent/orchestrator/tools/patch-manifest.json +10 -10
- package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +4 -5
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +4 -0
- package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +1 -1
- package/src/runtime/channels/backends/discord.mjs +5 -14
- package/src/runtime/channels/backends/telegram.mjs +0 -5
- package/src/runtime/channels/lib/inbound-handler.mjs +0 -1
- package/src/runtime/channels/lib/output-forwarder.mjs +24 -5
- package/src/runtime/channels/lib/scheduler.mjs +1 -1
- package/src/runtime/channels/lib/worker-main.mjs +1 -1
- package/src/runtime/media/renditions.mjs +21 -2
- package/src/runtime/memory/lib/tool-call-handler.mjs +16 -1
- package/src/runtime/memory/tool-defs.mjs +6 -6
- package/src/runtime/shared/atomic-file.mjs +53 -0
- package/src/runtime/shared/background-tasks.mjs +16 -6
- package/src/runtime/shared/child-spawn-gate.mjs +50 -26
- package/src/runtime/shared/resource-admission.mjs +40 -1
- package/src/runtime/shared/task-notification-envelope.mjs +11 -2
- package/src/runtime/shared/tool-card-model.mjs +6 -2
- package/src/runtime/shared/tool-status.mjs +10 -1
- package/src/runtime/shared/tool-surface.mjs +5 -0
- package/src/runtime/shared/turn-snapshot.mjs +385 -21
- package/src/runtime/shared/turn-worktree-snapshot.mjs +540 -0
- package/src/session-runtime/context-status.mjs +9 -3
- package/src/session-runtime/lifecycle-api.mjs +35 -5
- package/src/session-runtime/mcp-glue.mjs +11 -6
- package/src/session-runtime/provider-models.mjs +94 -43
- package/src/session-runtime/provider-usage.mjs +26 -2
- package/src/session-runtime/runtime-core.mjs +46 -8
- package/src/session-runtime/runtime-tunables.mjs +4 -0
- package/src/session-runtime/self-update.mjs +33 -4
- package/src/session-runtime/session-lifecycle.mjs +2 -0
- package/src/session-runtime/session-text.mjs +2 -1
- package/src/session-runtime/session-turn-api.mjs +106 -22
- package/src/session-runtime/workflow.mjs +11 -4
- package/src/standalone/agent-tool.mjs +4 -4
- package/src/standalone/backend-daemon.mjs +570 -0
- package/src/standalone/channel-daemon-transport.mjs +141 -1
- package/src/standalone/channel-worker.mjs +3 -2
- package/src/standalone/engine-daemon-client.mjs +894 -0
- package/src/standalone/engine-daemon-local-bridge.mjs +20 -0
- package/src/standalone/engine-daemon-protocol.mjs +33 -0
- package/src/standalone/engine-daemon-service.mjs +864 -0
- package/src/standalone/engine-daemon-transport.mjs +603 -0
- package/src/standalone/explore-tool.mjs +1 -1
- package/src/tui/App.jsx +62 -47
- package/src/tui/app/app-format.mjs +4 -2
- package/src/tui/app/app-view.jsx +5 -0
- package/src/tui/app/channel-pickers.mjs +7 -6
- package/src/tui/app/core-memory-picker.mjs +4 -4
- package/src/tui/app/extension-pickers.mjs +20 -18
- package/src/tui/app/maintenance-pickers.mjs +27 -27
- package/src/tui/app/onboarding-steps.mjs +23 -18
- package/src/tui/app/prompt-submit.mjs +13 -2
- package/src/tui/app/route-pickers.mjs +13 -8
- package/src/tui/app/settings-picker.mjs +55 -58
- package/src/tui/app/slash-dispatch.mjs +32 -28
- package/src/tui/app/transcript-window.mjs +19 -0
- package/src/tui/app/usage-context-panels.mjs +22 -7
- package/src/tui/app/use-mouse-input.mjs +25 -3
- package/src/tui/app/use-prompt-queue-history.mjs +31 -16
- package/src/tui/app/use-transcript-scroll.mjs +30 -8
- package/src/tui/app/use-transcript-window.mjs +22 -1
- package/src/tui/app/use-welcome-prompt-hint.mjs +2 -2
- package/src/tui/components/PromptInput.jsx +10 -0
- package/src/tui/components/Spinner.jsx +89 -86
- package/src/tui/components/TextEntryPanel.jsx +14 -0
- package/src/tui/components/ToolExecution.jsx +2 -2
- package/src/tui/dist/index.mjs +1537 -9718
- package/src/tui/engine/agent-job-feed.mjs +2 -2
- package/src/tui/engine/live-share.mjs +97 -4
- package/src/tui/engine/session-api-ext.mjs +23 -1
- package/src/tui/engine/session-api.mjs +88 -41
- package/src/tui/engine/session-flow.mjs +43 -4
- package/src/tui/engine/tool-card-results.mjs +6 -0
- package/src/tui/engine/turn.mjs +84 -4
- package/src/tui/engine-local-session.mjs +1108 -0
- package/src/tui/engine.mjs +16 -1057
- package/src/tui/index.jsx +47 -2
- package/src/tui/markdown/stream-fence.mjs +1 -1
- package/src/tui/spinner-meta.mjs +80 -0
- package/src/tui/spinner-verbs.mjs +35 -0
- package/src/ui/statusline-segments.mjs +43 -10
- package/src/ui/statusline.mjs +10 -1
- package/scripts/tmp-cdp-errors.mjs +0 -41
- package/scripts/tmp-cdp-inspect.mjs +0 -41
- package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +0 -176
- 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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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)
|
|
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
|
|
268
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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) =>
|
|
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 (!
|
|
358
|
+
if (!_regexSupportsMaxEffort(modelId)) {
|
|
269
359
|
levels = levels.filter((level) => level !== 'max');
|
|
270
360
|
}
|
|
271
|
-
if (!
|
|
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
|
-
|
|
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) => {
|
|
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
|
-
|
|
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
|
-
//
|
|
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).
|
|
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
|
-
//
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
|
39
|
-
//
|
|
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
|
-
//
|
|
53
|
-
//
|
|
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
|
|
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.
|
|
102
|
-
// request
|
|
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
|
-
//
|
|
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
|