mixdog 0.9.45 → 0.9.47

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 (123) hide show
  1. package/package.json +5 -2
  2. package/scripts/agent-dispatch-abort-compose-test.mjs +31 -0
  3. package/scripts/agent-model-liveness-test.mjs +618 -0
  4. package/scripts/agent-trace-io-test.mjs +68 -4
  5. package/scripts/bench-run.mjs +30 -3
  6. package/scripts/compact-pressure-test.mjs +187 -4
  7. package/scripts/compact-prior-context-flatten-test.mjs +252 -0
  8. package/scripts/compact-trigger-migration-smoke.mjs +8 -6
  9. package/scripts/compacted-placeholder-scrub-test.mjs +20 -34
  10. package/scripts/context-mcp-metering-test.mjs +75 -0
  11. package/scripts/explore-prompt-policy-test.mjs +15 -16
  12. package/scripts/explore-timeout-cancel-test.mjs +345 -0
  13. package/scripts/headless-pristine-execution-test.mjs +614 -0
  14. package/scripts/internal-comms-smoke.mjs +15 -6
  15. package/scripts/live-worker-smoke.mjs +1 -1
  16. package/scripts/memory-core-input-test.mjs +137 -0
  17. package/scripts/memory-rule-contract-test.mjs +5 -5
  18. package/scripts/openai-oauth-refresh-race-test.mjs +120 -0
  19. package/scripts/openai-oauth-ws-1006-retry-test.mjs +26 -0
  20. package/scripts/parent-abort-link-test.mjs +22 -0
  21. package/scripts/provider-toolcall-test.mjs +22 -0
  22. package/scripts/reactive-compact-persist-smoke.mjs +8 -4
  23. package/scripts/session-bench.mjs +3 -70
  24. package/scripts/task-bench.mjs +0 -2
  25. package/scripts/terminal-bench-isolation-guards-test.mjs +102 -0
  26. package/scripts/tool-smoke.mjs +21 -21
  27. package/scripts/tool-tui-presentation-test.mjs +68 -0
  28. package/scripts/tui-transcript-jitter-harness-entry.jsx +91 -10
  29. package/src/app.mjs +28 -103
  30. package/src/cli.mjs +17 -13
  31. package/src/defaults/cycle3-review-prompt.md +10 -4
  32. package/src/defaults/memory-promote-prompt.md +4 -3
  33. package/src/headless-command.mjs +139 -0
  34. package/src/headless-role.mjs +121 -10
  35. package/src/help.mjs +4 -1
  36. package/src/rules/agent/00-common.md +3 -3
  37. package/src/rules/agent/00-core.md +8 -9
  38. package/src/rules/agent/20-skip-protocol.md +2 -3
  39. package/src/rules/agent/30-explorer.md +50 -56
  40. package/src/rules/agent/40-cycle1-agent.md +10 -12
  41. package/src/rules/agent/41-cycle2-agent.md +12 -9
  42. package/src/rules/agent/42-cycle3-agent.md +4 -6
  43. package/src/rules/lead/01-general.md +5 -6
  44. package/src/rules/lead/02-channels.md +1 -1
  45. package/src/rules/lead/lead-brief.md +14 -17
  46. package/src/rules/lead/lead-tool.md +3 -3
  47. package/src/rules/shared/01-tool.md +41 -43
  48. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +46 -10
  49. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +44 -31
  50. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +18 -3
  51. package/src/runtime/agent/orchestrator/config.mjs +96 -30
  52. package/src/runtime/agent/orchestrator/context/collect.mjs +9 -0
  53. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +3 -0
  54. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +18 -5
  55. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +6 -6
  56. package/src/runtime/agent/orchestrator/providers/gemini.mjs +6 -6
  57. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +123 -3
  58. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +12 -3
  59. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +148 -30
  60. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +5 -7
  61. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +62 -14
  62. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +141 -19
  63. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +12 -4
  64. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +19 -1
  65. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +85 -17
  66. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +6 -8
  67. package/src/runtime/agent/orchestrator/providers/registry.mjs +47 -17
  68. package/src/runtime/agent/orchestrator/session/compact/summary.mjs +159 -20
  69. package/src/runtime/agent/orchestrator/session/context-utils.mjs +83 -10
  70. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +75 -7
  71. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +4 -374
  72. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +0 -75
  73. package/src/runtime/agent/orchestrator/session/loop/transcript-repair.mjs +0 -5
  74. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +20 -3
  75. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +32 -16
  76. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +5 -2
  77. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +86 -15
  78. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +30 -27
  79. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +1 -14
  80. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +21 -27
  81. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +1 -3
  82. package/src/runtime/agent/orchestrator/tools/builtin.mjs +1 -51
  83. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +4 -4
  84. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +1 -1
  85. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +26 -0
  86. package/src/runtime/memory/index.mjs +0 -1
  87. package/src/runtime/memory/lib/core-memory-store.mjs +44 -24
  88. package/src/runtime/memory/lib/http-router.mjs +0 -193
  89. package/src/runtime/memory/lib/memory-action-handlers.mjs +37 -11
  90. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +13 -5
  91. package/src/runtime/memory/lib/memory-cycle2.mjs +8 -5
  92. package/src/runtime/memory/lib/memory-cycle3.mjs +41 -6
  93. package/src/runtime/memory/lib/query-handlers.mjs +49 -6
  94. package/src/runtime/memory/lib/recall-format.mjs +21 -6
  95. package/src/runtime/memory/tool-defs.mjs +5 -6
  96. package/src/runtime/shared/config.mjs +11 -34
  97. package/src/runtime/shared/pristine-execution-contract.json +75 -0
  98. package/src/runtime/shared/pristine-execution.mjs +356 -0
  99. package/src/runtime/shared/provider-api-key.mjs +43 -0
  100. package/src/runtime/shared/provider-auth-binding.mjs +21 -0
  101. package/src/session-runtime/context-status.mjs +61 -13
  102. package/src/session-runtime/mcp-glue.mjs +29 -2
  103. package/src/session-runtime/plugin-mcp.mjs +7 -0
  104. package/src/session-runtime/resource-api.mjs +38 -5
  105. package/src/session-runtime/runtime-core.mjs +5 -1
  106. package/src/session-runtime/session-turn-api.mjs +14 -2
  107. package/src/session-runtime/settings-api.mjs +5 -0
  108. package/src/session-runtime/tool-catalog.mjs +13 -2
  109. package/src/session-runtime/tool-defs.mjs +1 -3
  110. package/src/standalone/agent-task-status.mjs +50 -11
  111. package/src/standalone/agent-tool/tool-def.mjs +1 -1
  112. package/src/standalone/explore-tool.mjs +257 -49
  113. package/src/standalone/seeds.mjs +1 -0
  114. package/src/tui/App.jsx +23 -10
  115. package/src/tui/app/use-transcript-scroll.mjs +4 -3
  116. package/src/tui/app/use-transcript-window.mjs +12 -21
  117. package/src/tui/components/ContextPanel.jsx +19 -25
  118. package/src/tui/dist/index.mjs +77 -65
  119. package/src/tui/engine/agent-envelope.mjs +16 -5
  120. package/src/tui/engine/labels.mjs +1 -1
  121. package/src/workflows/default/WORKFLOW.md +21 -51
  122. package/src/workflows/solo/WORKFLOW.md +12 -17
  123. package/src/workflows/solo-review/WORKFLOW.md +0 -47
@@ -252,6 +252,7 @@ export async function sendViaHttpSse({
252
252
  throw new Error('OpenAI OAuth HTTP fallback returned no response body');
253
253
  }
254
254
 
255
+ try { onStreamDelta?.('transport'); } catch {}
255
256
  try { onStageChange?.('streaming'); } catch {}
256
257
  const sseStartedAt = Date.now();
257
258
  const reader = response.body.getReader();
@@ -266,6 +267,13 @@ export async function sendViaHttpSse({
266
267
  // partial response surface as success — so record the abort reason and
267
268
  // re-throw it after the loop unblocks (see below).
268
269
  let _streamAbortReason = null;
270
+ let _pendingReadReject = null;
271
+ const _rejectPendingRead = (err) => {
272
+ if (!_pendingReadReject) return;
273
+ const reject = _pendingReadReject;
274
+ _pendingReadReject = null;
275
+ reject(err);
276
+ };
269
277
  let _onTotalAbort = null;
270
278
  if (totalTimeout.signal) {
271
279
  _onTotalAbort = () => {
@@ -274,6 +282,7 @@ export async function sendViaHttpSse({
274
282
  ? reason
275
283
  : new Error('OpenAI OAuth HTTP fallback aborted');
276
284
  try { reader.cancel(_streamAbortReason).catch(() => {}); } catch {}
285
+ _rejectPendingRead(_streamAbortReason);
277
286
  };
278
287
  if (totalTimeout.signal.aborted) _onTotalAbort();
279
288
  else totalTimeout.signal.addEventListener('abort', _onTotalAbort, { once: true });
@@ -286,11 +295,17 @@ export async function sendViaHttpSse({
286
295
  const _clearSemanticIdle = () => {
287
296
  if (_semanticIdleTimer) { clearTimeout(_semanticIdleTimer); _semanticIdleTimer = null; }
288
297
  };
298
+ const semanticIdleOverrideMs = Number(opts?._semanticIdleTimeoutMs);
299
+ const semanticIdleMs = Number.isFinite(semanticIdleOverrideMs) && semanticIdleOverrideMs > 0
300
+ ? semanticIdleOverrideMs
301
+ : PROVIDER_SEMANTIC_IDLE_TIMEOUT_MS;
302
+ const semanticIdleEnabled = (Number.isFinite(semanticIdleOverrideMs) && semanticIdleOverrideMs > 0)
303
+ || PROVIDER_SSE_IDLE_WATCHDOG_ENABLED;
289
304
  const _armSemanticIdle = () => {
290
- if (!PROVIDER_SSE_IDLE_WATCHDOG_ENABLED || !(PROVIDER_SEMANTIC_IDLE_TIMEOUT_MS > 0)) return;
305
+ if (!semanticIdleEnabled || !(semanticIdleMs > 0)) return;
291
306
  _clearSemanticIdle();
292
307
  _semanticIdleTimer = setTimeout(() => {
293
- _streamAbortReason = streamStalledError('OpenAI OAuth HTTP fallback', PROVIDER_SEMANTIC_IDLE_TIMEOUT_MS, { emittedToolCall: emittedToolCallIds.size > 0 });
308
+ _streamAbortReason = streamStalledError('OpenAI OAuth HTTP fallback', semanticIdleMs, { emittedToolCall: emittedToolCallIds.size > 0 });
294
309
  // Partial-final recovery: attach the
295
310
  // streamed partial state so the agent loop can accept a wedged FINAL
296
311
  // no-tool summary as a successful partial-final instead of dropping
@@ -310,7 +325,8 @@ export async function sendViaHttpSse({
310
325
  _streamAbortReason.partialModel = model || undefined;
311
326
  } catch { /* best-effort enrichment */ }
312
327
  try { reader.cancel(_streamAbortReason).catch(() => {}); } catch {}
313
- }, PROVIDER_SEMANTIC_IDLE_TIMEOUT_MS);
328
+ _rejectPendingRead(_streamAbortReason);
329
+ }, semanticIdleMs);
314
330
  try { _semanticIdleTimer.unref?.(); } catch {}
315
331
  };
316
332
  let buffer = '';
@@ -404,6 +420,7 @@ export async function sendViaHttpSse({
404
420
  };
405
421
  toolCalls.push(call);
406
422
  emitToolCall(call);
423
+ meaningful('tool');
407
424
  };
408
425
  const relayLeakText = (delta) => {
409
426
  if (!leakGuard.enabled) {
@@ -412,11 +429,13 @@ export async function sendViaHttpSse({
412
429
  emittedText = true;
413
430
  try { onTextDelta(delta); } catch {}
414
431
  }
432
+ if (delta) meaningful('text');
415
433
  return;
416
434
  }
417
435
  const { text, calls } = leakGuard.push(delta);
418
436
  if (text) {
419
437
  content += text;
438
+ meaningful('text');
420
439
  if (onTextDelta) {
421
440
  emittedText = true;
422
441
  try { onTextDelta(text); } catch {}
@@ -482,10 +501,10 @@ export async function sendViaHttpSse({
482
501
  toolCalls.push(call);
483
502
  emitToolCall(call);
484
503
  };
485
- const meaningful = () => {
504
+ const meaningful = (kind = 'semantic') => {
486
505
  if (ttftMs == null) ttftMs = Date.now() - sseStartedAt;
487
506
  _armSemanticIdle();
488
- try { onStreamDelta?.(); } catch {}
507
+ try { onStreamDelta?.(kind); } catch {}
489
508
  };
490
509
  const handleEvent = (event) => {
491
510
  if (!event || typeof event.type !== 'string') return;
@@ -493,14 +512,14 @@ export async function sendViaHttpSse({
493
512
  case 'response.created':
494
513
  if (event.response?.model) model = event.response.model;
495
514
  if (event.response?.id) responseId = event.response.id;
515
+ meaningful('semantic');
496
516
  break;
497
517
  case 'response.output_text.delta':
498
- meaningful();
499
518
  relayLeakText(event.delta || '');
500
519
  break;
501
520
  case 'response.reasoning_text.delta':
502
521
  case 'response.reasoning_summary_text.delta':
503
- meaningful();
522
+ if (event.delta) meaningful('reasoning');
504
523
  break;
505
524
  case 'response.output_item.added':
506
525
  if (event.item?.type === 'function_call') {
@@ -537,16 +556,17 @@ export async function sendViaHttpSse({
537
556
  markActiveToolItem(event.item);
538
557
  _toolInFlight = true;
539
558
  }
559
+ meaningful(_toolInFlight ? 'tool' : 'semantic');
540
560
  break;
541
561
  case 'response.function_call_arguments.delta':
542
562
  markActiveToolItem(null, event.item_id);
543
563
  _toolInFlight = true;
544
- meaningful();
564
+ meaningful('tool');
545
565
  break;
546
566
  case 'response.function_call_arguments.done': {
547
567
  const itemId = event.item_id || '';
548
568
  const pending = pendingCalls.get(itemId);
549
- if (pending?.kind === 'tool_search') { meaningful(); break; }
569
+ if (pending?.kind === 'tool_search') { meaningful('tool'); break; }
550
570
  const call = {
551
571
  id: pending?.callId || event.call_id || '',
552
572
  name: pending?.name || event.name || '',
@@ -558,13 +578,13 @@ export async function sendViaHttpSse({
558
578
  delete call._pendingItemId;
559
579
  emitToolCall(call);
560
580
  }
561
- meaningful();
581
+ meaningful('tool');
562
582
  break;
563
583
  }
564
584
  case 'response.custom_tool_call_input.delta':
565
585
  markActiveToolItem(null, event.item_id);
566
586
  _toolInFlight = true;
567
- meaningful();
587
+ meaningful('tool');
568
588
  break;
569
589
  case 'response.output_item.done': {
570
590
  const item = event.item || {};
@@ -597,8 +617,10 @@ export async function sendViaHttpSse({
597
617
  pushCustomToolCall(item);
598
618
  clearActiveToolItem(item, item.id || '');
599
619
  _toolInFlight = pendingCalls.size > 0 || activeToolItems.size > 0;
600
- meaningful();
601
620
  }
621
+ meaningful(item.type === 'reasoning'
622
+ ? 'reasoning'
623
+ : (/tool|function_call/.test(item.type || '') ? 'tool' : 'semantic'));
602
624
  break;
603
625
  }
604
626
  case 'response.completed': {
@@ -615,6 +637,7 @@ export async function sendViaHttpSse({
615
637
  raw: serviceTier ? { ...resp.usage, service_tier: serviceTier } : resp.usage,
616
638
  };
617
639
  }
640
+ let reportedBundleProgress = false;
618
641
  for (const item of resp.output || []) {
619
642
  if (item.type === 'message') {
620
643
  for (const part of item.content || []) {
@@ -627,22 +650,38 @@ export async function sendViaHttpSse({
627
650
  if (leakGuard.enabled) {
628
651
  const { text, calls } = leakGuard.push(part.text || '', true);
629
652
  content += text;
653
+ if (text) {
654
+ meaningful('text');
655
+ reportedBundleProgress = true;
656
+ }
630
657
  for (const c of calls) dispatchLeakedCall(c);
658
+ if (calls.length) reportedBundleProgress = true;
631
659
  } else {
632
660
  content += part.text || '';
661
+ if (part.text) {
662
+ meaningful('text');
663
+ reportedBundleProgress = true;
664
+ }
633
665
  }
634
666
  }
635
667
  if (part.type === 'output_text') _pushOutputTextAnnotations(part, citations, citationKeys);
636
668
  }
637
669
  } else if (item.type === 'reasoning') {
638
670
  pushReasoningItem(item);
671
+ meaningful('reasoning');
672
+ reportedBundleProgress = true;
639
673
  } else if (item.type === 'web_search_call') {
640
674
  pushWebSearchCall(item);
675
+ meaningful('tool');
676
+ reportedBundleProgress = true;
641
677
  } else if (item.type === 'tool_search_call') {
642
678
  pushToolSearchCall(item);
679
+ meaningful('tool');
680
+ reportedBundleProgress = true;
643
681
  } else if (item.type === 'custom_tool_call') {
644
682
  pushCustomToolCall(item);
645
- meaningful();
683
+ meaningful('tool');
684
+ reportedBundleProgress = true;
646
685
  } else if (item.type === 'function_call') {
647
686
  // Match the still-pending placeholder by item id, or
648
687
  // an already-recorded call by its canonical call_id —
@@ -667,8 +706,11 @@ export async function sendViaHttpSse({
667
706
  toolCalls.push(call);
668
707
  emitToolCall(call);
669
708
  }
709
+ meaningful('tool');
710
+ reportedBundleProgress = true;
670
711
  }
671
712
  }
713
+ if (!reportedBundleProgress) meaningful('semantic');
672
714
  completed = true;
673
715
  break;
674
716
  }
@@ -754,8 +796,13 @@ export async function sendViaHttpSse({
754
796
  throw reason instanceof Error ? reason : new Error('OpenAI OAuth HTTP fallback aborted');
755
797
  }
756
798
  if (_streamAbortReason) throw _streamAbortReason;
757
- const { value, done } = await reader.read();
799
+ const { value, done } = await new Promise((resolve, reject) => {
800
+ _pendingReadReject = reject;
801
+ reader.read().then(resolve, reject);
802
+ });
803
+ _pendingReadReject = null;
758
804
  if (done) break;
805
+ try { onStreamDelta?.('transport'); } catch {}
759
806
  buffer += decoder.decode(value, { stream: true });
760
807
  const parsed = _sseEventsFromBuffer(buffer);
761
808
  buffer = parsed.rest;
@@ -786,6 +833,7 @@ export async function sendViaHttpSse({
786
833
  _stampToolSafety(err);
787
834
  throw err;
788
835
  } finally {
836
+ _pendingReadReject = null;
789
837
  _clearSemanticIdle();
790
838
  try { reader.releaseLock?.(); } catch {}
791
839
  if (_onTotalAbort && totalTimeout.signal) {
@@ -24,6 +24,7 @@
24
24
  * parsing, and tracing.
25
25
  */
26
26
  import { createHash } from 'crypto';
27
+ import { performance } from 'node:perf_hooks';
27
28
  import {
28
29
  traceAgentFetch,
29
30
  traceAgentSse,
@@ -76,6 +77,7 @@ export {
76
77
  parseToolSearchArgs,
77
78
  _streamResponse,
78
79
  _cacheObservation as _cacheObservationForTest,
80
+ _cacheContinuityResetReason as _cacheContinuityResetReasonForTest,
79
81
  _warmupContinuityTrace as _warmupContinuityTraceForTest,
80
82
  };
81
83
 
@@ -412,7 +414,7 @@ export function _withCodexWsClientMetadata(frame, entry, enabled, context = {})
412
414
  };
413
415
  }
414
416
 
415
- function _cacheObservation({ entry, result }) {
417
+ function _cacheObservation({ entry, result, continuityResetReason = null }) {
416
418
  const inputTokens = _num(result?.usage?.inputTokens, 0);
417
419
  const promptTokens = _num(result?.usage?.promptTokens, 0) || inputTokens;
418
420
  const cachedTokens = _num(result?.usage?.cachedTokens, 0);
@@ -421,7 +423,11 @@ function _cacheObservation({ entry, result }) {
421
423
  const promptThreshold = _envPositiveInt('MIXDOG_OAI_CACHE_MISS_PROMPT_TOKENS', 4096);
422
424
  const dropRatio = _envRatio('MIXDOG_OAI_CACHE_MISS_DROP_RATIO', 0.6);
423
425
  const dropThreshold = Math.floor(previousMaxCached * dropRatio);
424
- const wasWarm = previousMaxCached >= warmThreshold;
426
+ // A full-frame chain break (most commonly compaction/input-prefix rewrite)
427
+ // starts a new prompt shape. Comparing its small new prompt against the
428
+ // old shape's lifetime high-water creates a "cache drop" on every following
429
+ // iteration until the new transcript grows past 60% of the old one.
430
+ const wasWarm = !continuityResetReason && previousMaxCached >= warmThreshold;
425
431
  const cacheRatio = promptTokens > 0 ? cachedTokens / promptTokens : null;
426
432
  const zeroMiss = wasWarm && promptTokens >= promptThreshold && cachedTokens === 0;
427
433
  const partialDrop = wasWarm
@@ -443,6 +449,7 @@ function _cacheObservation({ entry, result }) {
443
449
  dropThreshold,
444
450
  cacheRatio,
445
451
  actualMiss,
452
+ continuityResetReason,
446
453
  missReason: zeroMiss
447
454
  ? 'warm_session_zero_cached_tokens'
448
455
  : partialDrop
@@ -451,6 +458,34 @@ function _cacheObservation({ entry, result }) {
451
458
  };
452
459
  }
453
460
 
461
+ function _requestInputExtends(previousInput, currentInput) {
462
+ if (!Array.isArray(previousInput) || !Array.isArray(currentInput)) return false;
463
+ if (currentInput.length < previousInput.length) return false;
464
+ return previousInput.every(
465
+ (item, index) => _stableStringify(item) === _stableStringify(currentInput[index]),
466
+ );
467
+ }
468
+
469
+ function _cacheContinuityResetReason({ mode, deltaReason, entry, body }) {
470
+ if (mode === 'delta') return null;
471
+ if (deltaReason && !['no_anchor', 'full_forced', 'full_default'].includes(deltaReason)) {
472
+ return deltaReason;
473
+ }
474
+ // ws-full bypasses _computeDelta's structural comparisons and reports only
475
+ // full_default. Re-run the two cheap snapshot checks so compaction or any
476
+ // other prompt rewrite still retires the old prompt's cache high-water.
477
+ if (deltaReason !== 'full_default' || !entry?.lastResponseId) return null;
478
+ const currentSansInput = _stableStringify(_sansInput(body));
479
+ if (entry.lastRequestSansInput && currentSansInput !== entry.lastRequestSansInput) {
480
+ return 'request_properties_changed';
481
+ }
482
+ if (Array.isArray(entry.lastRequestInput)
483
+ && !_requestInputExtends(entry.lastRequestInput, Array.isArray(body?.input) ? body.input : [])) {
484
+ return 'input_prefix_mismatch';
485
+ }
486
+ return null;
487
+ }
488
+
454
489
  // Warmup→first-real continuity trace (Codex prewarm_websocket parity
455
490
  // observability). Pure/deterministic so it unit-tests without a live socket.
456
491
  // The R23 finding forbids the post-warmup request rewrite, so parity is
@@ -495,6 +530,7 @@ export async function _acquireWithRetry({
495
530
  codexHeaders,
496
531
  forceFresh,
497
532
  onRetry,
533
+ onBackoffSlept,
498
534
  externalSignal,
499
535
  _acquire = acquireWebSocket,
500
536
  _sleepFn = _defaultSleep,
@@ -560,8 +596,10 @@ export async function _acquireWithRetry({
560
596
  } catch {}
561
597
  // Sleep is abort-aware: an abort during backoff rejects immediately
562
598
  // instead of burning the remaining wait.
563
- if (externalSignal) {
564
- await new Promise((resolve, reject) => {
599
+ const sleepStart = performance.now();
600
+ try {
601
+ if (externalSignal) {
602
+ await new Promise((resolve, reject) => {
565
603
  const t = setTimeout(() => {
566
604
  externalSignal.removeEventListener('abort', onAbort);
567
605
  resolve();
@@ -573,9 +611,12 @@ export async function _acquireWithRetry({
573
611
  };
574
612
  if (externalSignal.aborted) { onAbort(); return; }
575
613
  externalSignal.addEventListener('abort', onAbort, { once: true });
576
- });
577
- } else {
578
- await _sleepFn(backoff);
614
+ });
615
+ } else {
616
+ await _sleepFn(backoff);
617
+ }
618
+ } finally {
619
+ try { onBackoffSlept?.(performance.now() - sleepStart); } catch {}
579
620
  }
580
621
  }
581
622
  }
@@ -624,6 +665,7 @@ export async function sendViaWebSocket({
624
665
  _streamFn = _streamResponse,
625
666
  _sendFrameFn = _sendFrame,
626
667
  _sleepFn = _defaultSleep,
668
+ _sendSpanTraceFn = appendAgentTrace,
627
669
  }) {
628
670
  // Bounded mid-stream retry: if an attempt's stream dies after
629
671
  // response.created but before response.completed from a transient cause
@@ -678,12 +720,53 @@ export async function sendViaWebSocket({
678
720
  const codexHandshakeHeaders = useCodexWsClientMetadata
679
721
  ? _codexWsCompatibilityHeaders({ ...codexMetadataContext, handshake: true })
680
722
  : null;
723
+ // One compact row per logical iteration. Values aggregate all handshake
724
+ // and mid-stream attempts, including warmup, without retaining request data.
725
+ const sendSpan = {
726
+ poolAcquireMs: 0,
727
+ requestBuildSerializationMs: 0,
728
+ preResponseCreatedMs: 0,
729
+ firstEventMs: 0,
730
+ retryBackoffMs: 0,
731
+ handshakeRetries: 0,
732
+ acquireAttempts: 0,
733
+ acquireMode: null,
734
+ emitted: false,
735
+ };
736
+ const emitSendSpan = (outcome) => {
737
+ if (sendSpan.emitted) return;
738
+ sendSpan.emitted = true;
739
+ const payload = {
740
+ provider: traceProvider,
741
+ model: useModel,
742
+ transport: 'websocket',
743
+ acquire_mode: sendSpan.acquireMode || 'failed',
744
+ acquire_attempts: sendSpan.acquireAttempts,
745
+ handshake_retries: sendSpan.handshakeRetries,
746
+ pool_acquire_ms: sendSpan.poolAcquireMs,
747
+ request_build_serialization_ms: sendSpan.requestBuildSerializationMs,
748
+ pre_response_created_ms: sendSpan.preResponseCreatedMs,
749
+ first_event_ms: sendSpan.firstEventMs,
750
+ retry_backoff_ms: sendSpan.retryBackoffMs,
751
+ outcome,
752
+ };
753
+ try {
754
+ _sendSpanTraceFn({
755
+ sessionId: poolKey,
756
+ iteration,
757
+ kind: 'send_spans',
758
+ ...payload,
759
+ payload,
760
+ });
761
+ } catch {}
762
+ };
681
763
 
682
764
  for (let attemptIndex = 0; attemptIndex <= MAX_MIDSTREAM_RETRIES; attemptIndex++) {
683
- const handshakeStart = Date.now();
765
+ const handshakeStart = performance.now();
684
766
  let acquired;
685
767
  let handshakeRetries = 0;
686
768
  const handshakeRetryClassifiers = [];
769
+ sendSpan.acquireAttempts += 1;
687
770
  try { onStageChange?.('requesting'); } catch {}
688
771
  try {
689
772
  acquired = await _acquireWithRetryFn({
@@ -703,17 +786,20 @@ export async function sendViaWebSocket({
703
786
  maxAttempts: (fastFallback && attemptIndex === 0) ? 1 : HANDSHAKE_MAX_ATTEMPTS,
704
787
  onRetry: (info) => {
705
788
  handshakeRetries += 1;
789
+ sendSpan.handshakeRetries += 1;
706
790
  if (info?.classifier) handshakeRetryClassifiers.push(info.classifier);
707
791
  },
792
+ onBackoffSlept: (ms) => { sendSpan.retryBackoffMs += ms; },
708
793
  });
709
794
  } catch (err) {
795
+ sendSpan.poolAcquireMs += performance.now() - handshakeStart;
710
796
  const classifier = err?.retryClassifier || (err?.code === 'EWSACQUIRETIMEOUT' ? 'acquire_timeout' : null);
711
797
  const classifiers = [...handshakeRetryClassifiers];
712
798
  if (classifier && !classifiers.includes(classifier)) classifiers.push(classifier);
713
799
  if (err?.httpStatus != null || classifier || handshakeRetries > 0 || classifiers.length > 0) {
714
800
  traceAgentFetch({
715
801
  sessionId: poolKey,
716
- headersMs: Date.now() - handshakeStart,
802
+ headersMs: performance.now() - handshakeStart,
717
803
  httpStatus: Number(err?.httpStatus || 0),
718
804
  provider: traceProvider,
719
805
  model: useModel,
@@ -727,11 +813,15 @@ export async function sendViaWebSocket({
727
813
  // the caller's turn actually tripped on).
728
814
  if (attemptIndex > 0 && firstAttemptError) {
729
815
  try { firstAttemptError.midstreamRetries = attemptIndex; } catch {}
816
+ emitSendSpan('error');
730
817
  throw _stampTool(_stampLiveText(firstAttemptError));
731
818
  }
819
+ emitSendSpan('error');
732
820
  throw _stampTool(_stampLiveText(err));
733
821
  }
734
822
  const { entry, reused } = acquired;
823
+ sendSpan.poolAcquireMs += performance.now() - handshakeStart;
824
+ sendSpan.acquireMode = entry?.ephemeral ? 'ephemeral' : (reused ? 'reused' : 'new');
735
825
  // Re-seed the retry attempt's fresh entry with the prior attempt's
736
826
  // last successful anchor so _computeDelta sees a non-null
737
827
  // lastInputPrefixHash and prev_response_id, keeping the same xAI
@@ -746,7 +836,7 @@ export async function sendViaWebSocket({
746
836
  }
747
837
  traceAgentFetch({
748
838
  sessionId: poolKey,
749
- headersMs: Date.now() - handshakeStart,
839
+ headersMs: performance.now() - handshakeStart,
750
840
  httpStatus: reused ? 0 : 101,
751
841
  provider: traceProvider,
752
842
  model: useModel,
@@ -801,6 +891,7 @@ export async function sendViaWebSocket({
801
891
  // has no prior request state. A reused pooled socket with a live
802
892
  // chain must go straight to the real request.
803
893
  if (warmupBody && typeof warmupBody === 'object' && attemptIndex === 0 && !entry.lastResponseId) {
894
+ const warmupBuildStart = performance.now();
804
895
  // Codex WS prewarm parity (opt-in): codex's prewarm frame is a
805
896
  // minimal generate:false request that omits transport-only
806
897
  // fields (stream/background) on the wire (prewarm_websocket,
@@ -824,7 +915,8 @@ export async function sendViaWebSocket({
824
915
  const wireWarmupFrame = _withCodexWsClientMetadata(warmupFrame, entry, useCodexWsClientMetadata, warmupMetadataContext);
825
916
  wireFrameHadTurnState = !!wireWarmupFrame?.client_metadata?.['x-codex-turn-state'];
826
917
  wireFrameMetadataTrace = _metadataTrace(wireWarmupFrame?.client_metadata);
827
- await _sendFrameFn(entry, wireWarmupFrame);
918
+ sendSpan.requestBuildSerializationMs += performance.now() - warmupBuildStart;
919
+ await _sendFrameFn(entry, wireWarmupFrame, sendSpan);
828
920
  const warmupStart = Date.now();
829
921
  const warmupState = {
830
922
  attemptIndex,
@@ -835,6 +927,8 @@ export async function sendViaWebSocket({
835
927
  model: useModel,
836
928
  traceProvider,
837
929
  warmup: true,
930
+ sendSpan,
931
+ sendStartedAt: performance.now(),
838
932
  };
839
933
  warmupResult = await _streamFn({
840
934
  entry,
@@ -914,6 +1008,7 @@ export async function sendViaWebSocket({
914
1008
  lastInputPrefixHash: null,
915
1009
  }
916
1010
  : entry;
1011
+ const requestBuildStart = performance.now();
917
1012
  const delta = _computeDelta({ entry: deltaEntry, body: requestBody, traceProvider });
918
1013
  ({ mode, frame } = delta);
919
1014
  deltaReason = delta.reason || null;
@@ -959,10 +1054,13 @@ export async function sendViaWebSocket({
959
1054
  const reason = externalSignal.reason;
960
1055
  throw reason instanceof Error ? reason : new Error('Aborted');
961
1056
  }
962
- await _sendFrameFn(entry, wireFrame);
1057
+ sendSpan.requestBuildSerializationMs += performance.now() - requestBuildStart;
1058
+ await _sendFrameFn(entry, wireFrame, sendSpan);
1059
+ midState.sendSpan = sendSpan;
1060
+ midState.sendStartedAt = performance.now();
963
1061
 
964
1062
  if (process.env.MIXDOG_DEBUG_AGENT) {
965
- process.stderr.write(`[agent-trace] ws-streaming-start sinceAcquire=${Date.now() - handshakeStart}ms\n`);
1063
+ process.stderr.write(`[agent-trace] ws-streaming-start sinceAcquire=${Math.round(performance.now() - handshakeStart)}ms\n`);
966
1064
  }
967
1065
  try { onStageChange?.('streaming'); } catch {}
968
1066
  result = await _streamFn({
@@ -1034,7 +1132,15 @@ export async function sendViaWebSocket({
1034
1132
  if (!process.env.MIXDOG_QUIET_PROVIDER_LOG) process.stderr.write(line);
1035
1133
  emittedProgress.push(line);
1036
1134
  } catch {}
1037
- await _sleepWithAbort(backoff, externalSignal, _sleepFn);
1135
+ const sleepStart = performance.now();
1136
+ try {
1137
+ await _sleepWithAbort(backoff, externalSignal, _sleepFn);
1138
+ } catch (sleepErr) {
1139
+ sendSpan.retryBackoffMs += performance.now() - sleepStart;
1140
+ emitSendSpan('error');
1141
+ throw sleepErr;
1142
+ }
1143
+ sendSpan.retryBackoffMs += performance.now() - sleepStart;
1038
1144
  continue;
1039
1145
  }
1040
1146
  // Not retryable, OR we've already exhausted the retry budget.
@@ -1056,8 +1162,10 @@ export async function sendViaWebSocket({
1056
1162
  firstAttemptError.suppressed = list;
1057
1163
  }
1058
1164
  } catch {}
1165
+ emitSendSpan('error');
1059
1166
  throw _stampTool(_stampLiveText(firstAttemptError));
1060
1167
  }
1168
+ emitSendSpan('error');
1061
1169
  throw _stampTool(_stampLiveText(err));
1062
1170
  }
1063
1171
  const liveModel = result.model || useModel;
@@ -1099,6 +1207,12 @@ export async function sendViaWebSocket({
1099
1207
  const priorEntryResponseId = typeof entry?.lastResponseId === 'string' && entry.lastResponseId.length > 0
1100
1208
  ? entry.lastResponseId
1101
1209
  : null;
1210
+ const cacheContinuityResetReason = _cacheContinuityResetReason({
1211
+ mode,
1212
+ deltaReason,
1213
+ entry,
1214
+ body: requestBody,
1215
+ });
1102
1216
  if (result.responseId && keepResponseChain) {
1103
1217
  entry.lastResponseId = result.responseId;
1104
1218
  entry.lastRequestSansInput = _stableStringify(_sansInput(requestBody));
@@ -1124,7 +1238,11 @@ export async function sendViaWebSocket({
1124
1238
  // warmup usage in first (R18) made prompt_tokens spike on it=1 and
1125
1239
  // then "shrink" on it=2, faking prefix-rewrite/cache-drop signals in
1126
1240
  // every warmup session (debugger 2026-07-03).
1127
- const cacheObservation = _cacheObservation({ entry, result });
1241
+ const cacheObservation = _cacheObservation({
1242
+ entry,
1243
+ result,
1244
+ continuityResetReason: cacheContinuityResetReason,
1245
+ });
1128
1246
  if (warmupResult?.usage) {
1129
1247
  result.usage = _combineUsageWithWarmup(result.usage, warmupResult.usage);
1130
1248
  }
@@ -1207,10 +1325,13 @@ export async function sendViaWebSocket({
1207
1325
  });
1208
1326
  } catch {}
1209
1327
  }
1210
- entry.promptCacheMaxCachedTokens = Math.max(
1211
- _num(entry.promptCacheMaxCachedTokens, 0),
1212
- cacheObservation.cachedTokens,
1213
- );
1328
+ // Rebase after a genuine provider retreat so one eviction produces one
1329
+ // diagnostic instead of a long run of duplicate "dropped" rows. The
1330
+ // request that exposed the retreat has already rebuilt the prefix; its
1331
+ // observed cached count is the correct baseline for recovery.
1332
+ entry.promptCacheMaxCachedTokens = (cacheObservation.actualMiss || cacheObservation.continuityResetReason)
1333
+ ? cacheObservation.cachedTokens
1334
+ : Math.max(_num(entry.promptCacheMaxCachedTokens, 0), cacheObservation.cachedTokens);
1214
1335
  // Early-session cache-miss ledger (first up-to-3 real requests on this
1215
1336
  // socket) for the warmup→first-real continuity trace below. Warmup
1216
1337
  // itself is excluded — this block only runs on the real send.
@@ -1340,6 +1461,7 @@ export async function sendViaWebSocket({
1340
1461
  // Leave a breadcrumb on the result so downstream callers can observe
1341
1462
  // that a retry was used (0 = first-try success, up to 2 for ws_1006/1011).
1342
1463
  try { Object.defineProperty(out, '__midstreamRetries', { value: attemptIndex, enumerable: false }); } catch {}
1464
+ emitSendSpan('ok');
1343
1465
  return out;
1344
1466
  }
1345
1467
  // Unreachable — the loop either returns or throws above.
@@ -9,11 +9,12 @@
9
9
  */
10
10
  import { createHash } from 'crypto';
11
11
  import { readFileSync, existsSync, mkdirSync, statSync, unlinkSync } from 'fs';
12
- import { join } from 'path';
12
+ import { join, resolve } from 'path';
13
13
  import { getPluginData } from '../config.mjs';
14
14
  import { enrichModels } from './model-catalog.mjs';
15
15
  import { sanitizeModelList } from './model-list-sanitize.mjs';
16
- import { writeJsonAtomicSync } from '../../../shared/atomic-file.mjs';
16
+ import { writeJsonAtomicSync, withFileLock } from '../../../shared/atomic-file.mjs';
17
+ import { boundProviderAuthPath } from '../../../shared/provider-auth-binding.mjs';
17
18
  import { makeModelCache } from './model-cache.mjs';
18
19
 
19
20
  import { sendViaWebSocket } from './openai-oauth-ws.mjs';
@@ -171,6 +172,10 @@ export async function ensureLatestCodexModel(provider) {
171
172
  }
172
173
 
173
174
  function getOwnTokenPath() {
175
+ const bound = boundProviderAuthPath('openai-oauth');
176
+ if (bound) return resolve(bound);
177
+ const explicit = process.env.OPENAI_OAUTH_CREDENTIALS_PATH;
178
+ if (explicit) return resolve(explicit);
174
179
  const dir = getPluginData();
175
180
  if (!existsSync(dir))
176
181
  mkdirSync(dir, { recursive: true });
@@ -255,6 +260,9 @@ function saveTokens(tokens) {
255
260
  const target = getOwnTokenPath();
256
261
  writeJsonAtomicSync(target, tokens, { lock: true, fsyncDir: true, mode: 0o600, secret: true });
257
262
  }
263
+ function getRefreshLockPath() {
264
+ return `${getOwnTokenPath()}.refresh.lock`;
265
+ }
258
266
 
259
267
  export function forgetOpenAIOAuthCredentials() {
260
268
  let removed = false;
@@ -682,7 +690,7 @@ export class OpenAIOAuthProvider {
682
690
  }
683
691
 
684
692
  const startingTokens = this.tokens || disk;
685
- _oauthRefreshInFlight = (async () => {
693
+ _oauthRefreshInFlight = withFileLock(getRefreshLockPath(), async () => {
686
694
  const latest = loadTokens() || startingTokens;
687
695
  const latestValidAfter = Date.now() + (force ? 0 : TOKEN_REFRESH_SKEW_MS);
688
696
  if (latest?.access_token && latest.access_token !== currentToken
@@ -720,7 +728,7 @@ export class OpenAIOAuthProvider {
720
728
  }
721
729
  throw new Error(`OpenAI OAuth token refresh failed (${msg}). Re-authenticate via provider login.`);
722
730
  }
723
- })().finally(() => { _oauthRefreshInFlight = null; });
731
+ }).finally(() => { _oauthRefreshInFlight = null; });
724
732
 
725
733
  this.tokens = await _oauthRefreshInFlight;
726
734
  return this.tokens;