mixdog 0.9.36 → 0.9.38

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 (115) hide show
  1. package/package.json +3 -2
  2. package/scripts/abort-recovery-test.mjs +118 -0
  3. package/scripts/compact-smoke.mjs +7 -7
  4. package/scripts/dispatch-persist-recovery-test.mjs +141 -0
  5. package/scripts/explore-bench-tmp.mjs +19 -0
  6. package/scripts/explore-bench.mjs +101 -14
  7. package/scripts/explore-prompt-policy-test.mjs +31 -0
  8. package/scripts/ingest-pure-conversation-smoke.mjs +11 -11
  9. package/scripts/notify-completion-mirror-test.mjs +73 -0
  10. package/scripts/openai-ws-early-settle-test.mjs +176 -0
  11. package/scripts/output-style-smoke.mjs +17 -17
  12. package/scripts/provider-toolcall-test.mjs +333 -14
  13. package/scripts/recall-bench-cases.json +3 -3
  14. package/scripts/recall-bench.mjs +1 -1
  15. package/scripts/recall-quality-cases.json +4 -4
  16. package/scripts/recall-usecase-cases.json +2 -2
  17. package/scripts/session-bench.mjs +13 -13
  18. package/scripts/session-sweep.mjs +266 -0
  19. package/scripts/steering-drain-buckets-test.mjs +72 -11
  20. package/scripts/submit-commandbusy-race-test.mjs +114 -0
  21. package/scripts/tool-smoke.mjs +83 -10
  22. package/src/app.mjs +2 -1
  23. package/src/defaults/cycle3-review-prompt.md +9 -0
  24. package/src/defaults/skills/setup/SKILL.md +93 -293
  25. package/src/lib/rules-builder.cjs +3 -2
  26. package/src/output-styles/default.md +2 -2
  27. package/src/output-styles/extreme-minimal.md +1 -1
  28. package/src/output-styles/minimal.md +1 -1
  29. package/src/output-styles/simple.md +2 -3
  30. package/src/rules/agent/30-explorer.md +40 -14
  31. package/src/rules/lead/01-general.md +4 -0
  32. package/src/rules/shared/01-tool.md +7 -3
  33. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +15 -3
  34. package/src/runtime/agent/orchestrator/config.mjs +1 -1
  35. package/src/runtime/agent/orchestrator/dispatch-persist.mjs +31 -8
  36. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +13 -7
  37. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +6 -6
  38. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +6 -6
  39. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +35 -5
  40. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +27 -0
  41. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +36 -37
  42. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +2 -2
  43. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +72 -1
  44. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +26 -3
  45. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +12 -28
  46. package/src/runtime/agent/orchestrator/providers/openai-transport-policy.mjs +18 -15
  47. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +5 -2
  48. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +119 -3
  49. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +113 -2
  50. package/src/runtime/agent/orchestrator/providers/registry.mjs +44 -5
  51. package/src/runtime/agent/orchestrator/providers/tool-stream-state.mjs +78 -0
  52. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +57 -31
  53. package/src/runtime/agent/orchestrator/session/cache/scoped-cache.mjs +8 -6
  54. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +28 -2
  55. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +1 -3
  56. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +15 -2
  57. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +30 -55
  58. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +2 -2
  59. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +24 -16
  60. package/src/runtime/agent/orchestrator/session/result-classification.mjs +2 -0
  61. package/src/runtime/agent/orchestrator/session/store.mjs +116 -21
  62. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +5 -2
  63. package/src/runtime/agent/orchestrator/stall-policy.mjs +55 -0
  64. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +35 -20
  65. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +9 -9
  66. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +77 -31
  67. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +25 -3
  68. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +9 -2
  69. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +73 -25
  70. package/src/runtime/agent/orchestrator/tools/builtin.mjs +2 -1
  71. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +17 -7
  72. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +14 -12
  73. package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +5 -0
  74. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +56 -6
  75. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +1 -1
  76. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +9 -0
  77. package/src/runtime/channels/lib/worker-main.mjs +5 -0
  78. package/src/runtime/memory/lib/core-memory-store.mjs +104 -0
  79. package/src/runtime/memory/lib/ko-morph.mjs +1 -1
  80. package/src/runtime/memory/lib/memory-cycle3.mjs +64 -4
  81. package/src/runtime/memory/lib/memory-text-utils.mjs +4 -4
  82. package/src/runtime/memory/lib/recall-format.mjs +3 -3
  83. package/src/runtime/shared/child-spawn-gate.mjs +15 -0
  84. package/src/runtime/shared/config.mjs +98 -12
  85. package/src/runtime/shared/process-listener-headroom.mjs +12 -0
  86. package/src/session-runtime/cwd-plugins.mjs +6 -3
  87. package/src/session-runtime/model-route-api.mjs +50 -2
  88. package/src/session-runtime/provider-auth-api.mjs +8 -1
  89. package/src/session-runtime/provider-models.mjs +3 -3
  90. package/src/session-runtime/resource-api.mjs +22 -0
  91. package/src/session-runtime/runtime-core.mjs +56 -10
  92. package/src/session-runtime/settings-api.mjs +11 -2
  93. package/src/session-runtime/warmup-schedulers.mjs +7 -1
  94. package/src/standalone/agent-tool.mjs +15 -9
  95. package/src/standalone/explore-tool.mjs +4 -1
  96. package/src/tui/App.jsx +6 -5
  97. package/src/tui/app/transcript-window.mjs +60 -10
  98. package/src/tui/app/use-transcript-window.mjs +2 -1
  99. package/src/tui/components/ContextPanel.jsx +4 -1
  100. package/src/tui/components/ToolExecution.jsx +15 -12
  101. package/src/tui/components/TranscriptItem.jsx +1 -1
  102. package/src/tui/components/tool-execution/surface-detail.mjs +8 -8
  103. package/src/tui/dist/index.mjs +482 -151
  104. package/src/tui/engine/agent-job-feed.mjs +36 -6
  105. package/src/tui/engine/notification-plan.mjs +3 -3
  106. package/src/tui/engine/queue-helpers.mjs +8 -0
  107. package/src/tui/engine/render-timing.mjs +104 -8
  108. package/src/tui/engine/session-api.mjs +58 -3
  109. package/src/tui/engine/session-flow.mjs +93 -29
  110. package/src/tui/engine/tool-card-results.mjs +28 -13
  111. package/src/tui/engine/turn.mjs +238 -157
  112. package/src/tui/engine.mjs +17 -8
  113. package/src/tui/index.jsx +10 -1
  114. package/src/workflows/default/WORKFLOW.md +8 -4
  115. package/src/workflows/solo/WORKFLOW.md +8 -4
@@ -26,6 +26,7 @@ import { makeInvalidToolArgsMarker } from './openai-compat-stream.mjs';
26
26
  import { createLeakGuard, createToolCallDedupe, dedupeToolCallList } from './anthropic-leaked-toolcall.mjs';
27
27
  import { customToolCallFromResponseItem } from './custom-tool-wire.mjs';
28
28
  import { CODEX_OAUTH_ORIGINATOR, CODEX_RESPONSES_URL, _displayCodexModel } from './openai-oauth.mjs';
29
+ import { createActiveToolItemTracker } from './tool-stream-state.mjs';
29
30
 
30
31
  // Public OpenAI Responses API endpoint for the api-key `openai` provider.
31
32
  // The openai-direct WS transport hits the same origin (openai-ws-pool
@@ -297,7 +298,15 @@ export async function sendViaHttpSse({
297
298
  try {
298
299
  _streamAbortReason.partialContent = content;
299
300
  _streamAbortReason.partialToolCalls = toolCalls.length ? toolCalls.slice() : undefined;
300
- _streamAbortReason.pendingToolUse = pendingCalls.size > 0 || emittedToolCallIds.size > 0;
301
+ // Shared active tool-item tracker (tool-stream-state.mjs) closes
302
+ // the custom-tool gap: a stall mid custom_tool_call_input.delta
303
+ // never lands in pendingCalls, so without activeToolItems/_toolInFlight
304
+ // a tool-bearing turn would look text-only and be wrongly accepted
305
+ // as a partial-final.
306
+ _streamAbortReason.pendingToolUse = pendingCalls.size > 0
307
+ || emittedToolCallIds.size > 0
308
+ || activeToolItems.size > 0
309
+ || _toolInFlight === true;
301
310
  _streamAbortReason.partialModel = model || undefined;
302
311
  } catch { /* best-effort enrichment */ }
303
312
  try { reader.cancel(_streamAbortReason).catch(() => {}); } catch {}
@@ -313,6 +322,15 @@ export async function sendViaHttpSse({
313
322
  let ttftMs = null;
314
323
  const toolCalls = [];
315
324
  const pendingCalls = new Map();
325
+ // Active tool-item / alias tracking shared with the WS + compat Responses
326
+ // streams (tool-stream-state.mjs). Mark on output_item.added / arg-input
327
+ // deltas, clear on output_item.done; _toolInFlight latches tool work the
328
+ // moment a call's input starts streaming (before it lands in pendingCalls).
329
+ const _toolTracker = createActiveToolItemTracker();
330
+ const activeToolItems = _toolTracker.items;
331
+ const markActiveToolItem = _toolTracker.mark;
332
+ const clearActiveToolItem = _toolTracker.clear;
333
+ let _toolInFlight = false;
316
334
  const reasoningItems = [];
317
335
  const citations = [];
318
336
  const citationKeys = new Set();
@@ -486,10 +504,12 @@ export async function sendViaHttpSse({
486
504
  break;
487
505
  case 'response.output_item.added':
488
506
  if (event.item?.type === 'function_call') {
507
+ markActiveToolItem(event.item);
489
508
  pendingCalls.set(event.item.id || '', {
490
509
  name: event.item.name || '',
491
510
  callId: event.item.call_id || '',
492
511
  });
512
+ _toolInFlight = true;
493
513
  } else if (event.item?.type === 'tool_search_call') {
494
514
  // Mark tool_search as in-flight the moment the item is
495
515
  // added, mirroring function_call above, so the semantic
@@ -506,9 +526,21 @@ export async function sendViaHttpSse({
506
526
  kind: 'tool_search',
507
527
  });
508
528
  }
529
+ markActiveToolItem(event.item);
530
+ _toolInFlight = true;
531
+ } else if (event.item?.type === 'custom_tool_call') {
532
+ // Custom tool calls surface no pendingCalls entry, so mark
533
+ // the item active at added-time (mirroring function_call /
534
+ // tool_search_call above). The later custom_tool_call_input.delta
535
+ // still marks too, but a stall between added and the first
536
+ // input delta must already read as pendingToolUse.
537
+ markActiveToolItem(event.item);
538
+ _toolInFlight = true;
509
539
  }
510
540
  break;
511
541
  case 'response.function_call_arguments.delta':
542
+ markActiveToolItem(null, event.item_id);
543
+ _toolInFlight = true;
512
544
  meaningful();
513
545
  break;
514
546
  case 'response.function_call_arguments.done': {
@@ -530,6 +562,8 @@ export async function sendViaHttpSse({
530
562
  break;
531
563
  }
532
564
  case 'response.custom_tool_call_input.delta':
565
+ markActiveToolItem(null, event.item_id);
566
+ _toolInFlight = true;
533
567
  meaningful();
534
568
  break;
535
569
  case 'response.output_item.done': {
@@ -546,11 +580,23 @@ export async function sendViaHttpSse({
546
580
  emitToolCall(tc);
547
581
  }
548
582
  }
583
+ // Drop the resolved function item from pendingCalls before
584
+ // recomputing _toolInFlight (mirrors tool_search_call below
585
+ // and the compat path) — otherwise a completed call keeps
586
+ // pendingCalls.size > 0 and the latch never clears, so a
587
+ // later max-output cutoff is misread as a tool in flight.
588
+ pendingCalls.delete(item.id || '');
589
+ clearActiveToolItem(item, item.id || '');
590
+ _toolInFlight = pendingCalls.size > 0 || activeToolItems.size > 0;
549
591
  } else if (item.type === 'tool_search_call') {
550
592
  pendingCalls.delete(item.id || '');
551
593
  pushToolSearchCall(item);
594
+ clearActiveToolItem(item, item.id || '');
595
+ _toolInFlight = pendingCalls.size > 0 || activeToolItems.size > 0;
552
596
  } else if (item.type === 'custom_tool_call') {
553
597
  pushCustomToolCall(item);
598
+ clearActiveToolItem(item, item.id || '');
599
+ _toolInFlight = pendingCalls.size > 0 || activeToolItems.size > 0;
554
600
  meaningful();
555
601
  }
556
602
  break;
@@ -636,6 +682,20 @@ export async function sendViaHttpSse({
636
682
  } else if (event.response.status === 'incomplete') {
637
683
  const reason = _incompleteReasonFromEvent(event);
638
684
  if (_isMaxOutputIncompleteReason(reason)) {
685
+ // Max-output cutoff with a function/custom/tool_search
686
+ // still in flight means the tool arguments were
687
+ // truncated — do NOT mark a clean completion (mirrors
688
+ // the compat Responses path), or partial args surface as
689
+ // a successful tool call. Throw a stream-stalled
690
+ // pendingToolUse error so the loop gates/retries.
691
+ if (pendingCalls.size > 0 || activeToolItems.size > 0 || _toolInFlight === true) {
692
+ const err = _stampToolSafety(new Error('OpenAI OAuth HTTP fallback response.done incomplete (max_output_tokens) with tool call in flight'));
693
+ err.streamStalled = true;
694
+ err.pendingToolUse = true;
695
+ err.partialContent = content;
696
+ err.partialModel = model || undefined;
697
+ throw err;
698
+ }
639
699
  completed = true;
640
700
  stopReason = 'length';
641
701
  break;
@@ -652,6 +712,17 @@ export async function sendViaHttpSse({
652
712
  case 'response.incomplete': {
653
713
  const reason = _incompleteReasonFromEvent(event);
654
714
  if (_isMaxOutputIncompleteReason(reason)) {
715
+ // See response.done incomplete above: a max-output cutoff
716
+ // while a tool is in flight is a truncated tool call, not a
717
+ // clean length completion.
718
+ if (pendingCalls.size > 0 || activeToolItems.size > 0 || _toolInFlight === true) {
719
+ const err = _stampToolSafety(new Error('OpenAI OAuth HTTP fallback response.incomplete (max_output_tokens) with tool call in flight'));
720
+ err.streamStalled = true;
721
+ err.pendingToolUse = true;
722
+ err.partialContent = content;
723
+ err.partialModel = model || undefined;
724
+ throw err;
725
+ }
655
726
  completed = true;
656
727
  stopReason = 'length';
657
728
  break;
@@ -895,7 +895,26 @@ export async function sendViaWebSocket({
895
895
  // Delta opt-in still chains via entry.lastResponseId above.
896
896
  }
897
897
 
898
- const delta = _computeDelta({ entry, body: requestBody, traceProvider });
898
+ // Warmup writes the same prefix with generate:false, but the first
899
+ // real response must still be a FULL generating frame. Reusing the
900
+ // warmup response_id here would make _computeDelta reduce the frame
901
+ // input to [] when the warmup input matches, and a generate:false
902
+ // warmup is not a chainable response to continue from — so the first
903
+ // real turn would generate from an empty frame. Keep the warmup state
904
+ // for cache/trace, but compute the main frame as cold (full input +
905
+ // instructions).
906
+ const deltaEntry = warmupResult
907
+ ? {
908
+ ...entry,
909
+ lastResponseId: null,
910
+ lastRequestSansInput: null,
911
+ lastRequestInput: null,
912
+ lastResponseItems: null,
913
+ lastInputLen: 0,
914
+ lastInputPrefixHash: null,
915
+ }
916
+ : entry;
917
+ const delta = _computeDelta({ entry: deltaEntry, body: requestBody, traceProvider });
899
918
  ({ mode, frame } = delta);
900
919
  deltaReason = delta.reason || null;
901
920
  strippedResponseItems = delta.strippedResponseItems || 0;
@@ -1063,7 +1082,11 @@ export async function sendViaWebSocket({
1063
1082
  // own, so retaining the anchor cannot corrupt the cache — it only adds
1064
1083
  // a delta fast-path when the items DO match.
1065
1084
  const keepResponseChain = !!result.responseId;
1066
- const keepSocket = true;
1085
+ // Normally the socket is pooled for reuse. But an early tool-call settle
1086
+ // (result.closeSocket) means the stream resolved before
1087
+ // response.completed/done arrived: the server may still emit those as
1088
+ // orphan frames, so the socket must be discarded, not reused.
1089
+ const keepSocket = !result.closeSocket;
1067
1090
 
1068
1091
  // Update cache state for the next iteration in this session. openai-oauth
1069
1092
  // keeps the previous response anchor even when the model emitted tool
@@ -1300,7 +1323,7 @@ export async function sendViaWebSocket({
1300
1323
  } catch {}
1301
1324
 
1302
1325
  releaseWebSocket({ entry, poolKey, keep: keepSocket });
1303
- const { responseId: _ignored, responseItems: _responseItemsIgnored, ...out } = result;
1326
+ const { responseId: _ignored, responseItems: _responseItemsIgnored, closeSocket: _closeSocketIgnored, ...out } = result;
1304
1327
  if (includeResponseId && result.responseId) out.responseId = result.responseId;
1305
1328
  if (warmupResult) {
1306
1329
  try {
@@ -410,16 +410,6 @@ function convertMessagesToResponsesInput(messages, opts = {}) {
410
410
  for (const m of messages) {
411
411
  if (!m || m.role === 'system') continue;
412
412
  if (m.role === 'tool') {
413
- if (Array.isArray(m.nativeToolSearch?.openaiTools)) {
414
- out.push({
415
- type: 'tool_search_output',
416
- call_id: m.toolCallId || '',
417
- status: 'completed',
418
- execution: 'client',
419
- tools: m.nativeToolSearch.openaiTools,
420
- });
421
- continue;
422
- }
423
413
  const { output, mediaContent } = splitToolContentForOpenAIResponses(m.content);
424
414
  if (customToolCallNameById.has(m.toolCallId || '')) {
425
415
  out.push({
@@ -449,14 +439,7 @@ function convertMessagesToResponsesInput(messages, opts = {}) {
449
439
  // reasoning in `input` triggers "Duplicate item".
450
440
  if (m.content) out.push(wireMessage('assistant', normalizeContentForOpenAIResponses(m.content, { role: 'assistant' })));
451
441
  for (const tc of m.toolCalls) {
452
- if (tc.nativeType === 'tool_search_call' || tc.name === 'load_tool' || tc.name === 'tool_search') {
453
- out.push({
454
- type: 'tool_search_call',
455
- call_id: tc.id,
456
- execution: 'client',
457
- arguments: tc.arguments || {},
458
- });
459
- } else if (isCustomToolCallRecord(tc)) {
442
+ if (isCustomToolCallRecord(tc)) {
460
443
  if (tc.id) customToolCallNameById.set(tc.id, tc.name || '');
461
444
  out.push({
462
445
  type: 'custom_tool_call',
@@ -468,7 +451,7 @@ function convertMessagesToResponsesInput(messages, opts = {}) {
468
451
  out.push({
469
452
  type: 'function_call',
470
453
  call_id: tc.id,
471
- name: tc.name,
454
+ name: tc.name === 'tool_search' ? 'load_tool' : tc.name,
472
455
  arguments: JSON.stringify(tc.arguments),
473
456
  });
474
457
  }
@@ -487,8 +470,8 @@ function convertMessagesToResponsesInput(messages, opts = {}) {
487
470
  function toOpenAIResponsesTool(t) {
488
471
  if (t?.name === 'load_tool' || t?.name === 'tool_search') {
489
472
  return {
490
- type: 'tool_search',
491
- execution: 'client',
473
+ type: 'function',
474
+ name: 'load_tool',
492
475
  description: t.description,
493
476
  parameters: t.inputSchema,
494
477
  };
@@ -923,13 +906,14 @@ export class OpenAIOAuthProvider {
923
906
  useModel,
924
907
  displayModel: _displayCodexModel,
925
908
  forceFresh,
926
- // Fast-fallback: when HTTP/SSE fallback is enabled, cap the WS
927
- // handshake acquire loop at ONE attempt so a first
928
- // acquire/first-byte failure aborts the remaining backoff retries
929
- // and lets HTTP start immediately (skip-retries, no concurrent race
930
- // no double token spend). WS-only paths (fallback disabled) keep
931
- // the full retry budget.
932
- fastFallback: httpFallbackEnabled,
909
+ // Default refs-style recovery: keep using WS first. A transient
910
+ // first-byte / mid-stream stall closes the bad socket and retries on
911
+ // a fresh WS entry; only after the bounded WS retry budget is
912
+ // exhausted does openai-oauth fall back to HTTP/SSE. This preserves
913
+ // the hot WS/cache path for temporary blips while still preventing
914
+ // TUI-level hangs. Operators can opt into immediate HTTP fallback
915
+ // for diagnostics with MIXDOG_OPENAI_OAUTH_FAST_HTTP_FALLBACK=1.
916
+ fastFallback: httpFallbackEnabled && _envFlag('MIXDOG_OPENAI_OAUTH_FAST_HTTP_FALLBACK', false),
933
917
  // codex-parity prewarm (generate:false full frame on a fresh
934
918
  // socket, wire-verified 2026-07-03). DEFAULT ON: R19(off) vs
935
919
  // R20(on) A/B shows prewarm removes ALL early-session zero-cache
@@ -3,20 +3,20 @@
3
3
  * Responses-API transport policy switch (OpenAI OAuth/direct + xAI/compat).
4
4
  *
5
5
  * One env knob, MIXDOG_OAI_TRANSPORT, selects among the transport modes:
6
- * - 'ws-delta' (DEFAULT): WS transport, refs-compatible delta ON.
6
+ * - 'ws-delta' WS-only transport, refs-compatible delta ON.
7
7
  * - 'ws-full' WS transport, delta OFF (always full frames).
8
8
  * - 'http-sse' force the HTTP/SSE transport directly (delta is WS-only, so
9
9
  * it stays off).
10
- * - 'auto' compatibility spelling for the default ws-delta route.
10
+ * - 'auto' WS-first route with HTTP/SSE fallback on websocket failure.
11
11
  *
12
- * No mode performs an implicit HTTP fallback: explicit modes pin their
13
- * transport. Delta is selected solely via MIXDOG_OAI_TRANSPORT=ws-delta
14
- * (or by leaving the env unset).
12
+ * Default/unset behaves like 'auto': prefer WS/delta for cache hotness, but
13
+ * fall back to HTTP/SSE after bounded websocket failures. Explicit ws-* modes
14
+ * pin WS for experiments; http-sse pins HTTP.
15
15
  */
16
16
 
17
17
  // Normalize the transport mode token. Underscores/spaces and a few common
18
18
  // spellings collapse to the canonical modes. Unknown/empty → null so the
19
- // caller falls back to the default 'ws-delta'.
19
+ // caller falls back to the default 'auto'.
20
20
  export function _normalizeTransportMode(raw) {
21
21
  const v = String(raw || '').trim().toLowerCase().replace(/[\s_]+/g, '-');
22
22
  switch (v) {
@@ -27,7 +27,7 @@ export function _normalizeTransportMode(raw) {
27
27
  case 'http-sse': case 'httpsse': case 'http': case 'sse': case 'http/sse':
28
28
  return 'http-sse';
29
29
  case 'auto':
30
- return 'ws-delta';
30
+ return 'auto';
31
31
  default:
32
32
  return null;
33
33
  }
@@ -63,9 +63,9 @@ export const RESPONSES_TRANSPORT_CAPABILITIES = Object.freeze({
63
63
  export function _gateTransportMode(mode, caps) {
64
64
  let m = mode;
65
65
  // Delta unsupported → keep WS transport but force full frames.
66
- if (m === 'ws-delta' && !caps.delta) m = 'ws-full';
66
+ if ((m === 'auto' || m === 'ws-delta') && !caps.delta) m = caps.ws ? 'ws-full' : (caps.http ? 'http-sse' : 'auto');
67
67
  // WS unsupported → prefer HTTP, else defer to auto.
68
- if ((m === 'ws-full' || m === 'ws-delta') && !caps.ws) m = caps.http ? 'http-sse' : 'ws-delta';
68
+ if ((m === 'auto' || m === 'ws-full' || m === 'ws-delta') && !caps.ws) m = caps.http ? 'http-sse' : 'auto';
69
69
  // HTTP unsupported → prefer full-frame WS, else defer to auto.
70
70
  if (m === 'http-sse' && !caps.http) m = caps.ws ? 'ws-full' : 'auto';
71
71
  return m;
@@ -76,8 +76,8 @@ export function _gateTransportMode(mode, caps) {
76
76
  * per-provider capabilities.
77
77
  * @param {Record<string,string|undefined>} [env=process.env]
78
78
  * @param {{ws?:boolean,http?:boolean,delta?:boolean}} [capabilities=FULL_RESPONSES_TRANSPORT_CAPS]
79
- * @returns {{ mode: 'ws-full'|'ws-delta'|'http-sse',
80
- * requestedMode: 'ws-full'|'ws-delta'|'http-sse',
79
+ * @returns {{ mode: 'auto'|'ws-full'|'ws-delta'|'http-sse',
80
+ * requestedMode: 'auto'|'ws-full'|'ws-delta'|'http-sse',
81
81
  * transport: 'auto'|'ws'|'http',
82
82
  * allowHttpFallback: boolean,
83
83
  * delta: { force: boolean, refs: boolean, optIn: boolean },
@@ -85,7 +85,7 @@ export function _gateTransportMode(mode, caps) {
85
85
  */
86
86
  export function resolveResponsesTransportPolicy(env = process.env, capabilities = FULL_RESPONSES_TRANSPORT_CAPS) {
87
87
  const caps = { ...FULL_RESPONSES_TRANSPORT_CAPS, ...(capabilities || {}) };
88
- const requestedMode = _normalizeTransportMode(env?.MIXDOG_OAI_TRANSPORT) || 'ws-delta';
88
+ const requestedMode = _normalizeTransportMode(env?.MIXDOG_OAI_TRANSPORT) || 'auto';
89
89
  const mode = _gateTransportMode(requestedMode, caps);
90
90
  let transport;
91
91
  let delta;
@@ -98,6 +98,7 @@ export function resolveResponsesTransportPolicy(env = process.env, capabilities
98
98
  transport = 'ws';
99
99
  delta = DELTA_OFF; // explicit full frames
100
100
  break;
101
+ case 'auto':
101
102
  case 'ws-delta':
102
103
  transport = 'ws';
103
104
  // Reachable only when caps.delta is true (else gated to ws-full).
@@ -112,9 +113,11 @@ export function resolveResponsesTransportPolicy(env = process.env, capabilities
112
113
  mode,
113
114
  requestedMode,
114
115
  transport,
115
- // No mode performs an implicit HTTP fallback; explicit http-sse pins the
116
- // HTTP transport instead. Kept as a field so existing callers gate off.
117
- allowHttpFallback: false,
116
+ // Codex refs behavior: default/auto is WS-first but not WS-only. If the
117
+ // websocket path stalls/fails before emitting live output, callers may
118
+ // replay the request over HTTP/SSE. Explicit ws-* modes remain pinned
119
+ // for transport experiments; explicit http-sse bypasses WS entirely.
120
+ allowHttpFallback: requestedMode === 'auto' && caps.http,
118
121
  delta,
119
122
  capabilities: caps,
120
123
  };
@@ -195,8 +195,11 @@ const TRANSPORT_ONLY_FRAME_FIELDS = new Set(['stream', 'background']);
195
195
  // identical byte-for-byte: `type` always leads, then the body's codex
196
196
  // struct-order keys follow verbatim. A delta send passes previousResponseId
197
197
  // (inserted immediately before `input`, matching codex's refs position) and
198
- // inputOverride (the stripped tail); an empty instructions string is dropped
199
- // in that case because the server resolves it from previous_response_id.
198
+ // inputOverride (the stripped tail). `instructions` MUST still be resent on
199
+ // previous_response_id frames: per the OpenAI Responses API, the previous
200
+ // response's top-level instructions are NOT carried over to the chained
201
+ // response, so dropping them here strips the system/lead prompt from every
202
+ // continuation turn. Only an empty instructions string is omitted.
200
203
  // Full/warmup frames pass the body unchanged and keep every key in place.
201
204
  // omitTransportFields is used by wire-parity/prewarm helpers to drop stream/background.
202
205
  export function _buildResponseCreateFrame(body, { previousResponseId = null, inputOverride, omitTransportFields = false } = {}) {
@@ -15,6 +15,10 @@ import { codexOriginator, codexUserAgent, codexVersionHeader } from './codex-cli
15
15
  import {
16
16
  PROVIDER_WS_ACQUIRE_TIMEOUT_MS,
17
17
  PROVIDER_WS_HANDSHAKE_TIMEOUT_MS,
18
+ PROVIDER_WS_PING_ENABLED,
19
+ PROVIDER_WS_PING_INTERVAL_MS,
20
+ PROVIDER_WS_PONG_TIMEOUT_MS,
21
+ PROVIDER_WS_LIVENESS_STALE_MS,
18
22
  resolveTimeoutMs,
19
23
  } from '../stall-policy.mjs';
20
24
 
@@ -36,6 +40,10 @@ export const WS_IDLE_MS = resolveTimeoutMs(
36
40
  );
37
41
  const WS_HANDSHAKE_TIMEOUT_MS = PROVIDER_WS_HANDSHAKE_TIMEOUT_MS;
38
42
  const WS_ACQUIRE_TIMEOUT_MS = PROVIDER_WS_ACQUIRE_TIMEOUT_MS;
43
+ const WS_PING_INTERVAL_MS = PROVIDER_WS_PING_INTERVAL_MS;
44
+ const WS_PONG_TIMEOUT_MS = PROVIDER_WS_PONG_TIMEOUT_MS;
45
+ const WS_LIVENESS_STALE_MS = PROVIDER_WS_LIVENESS_STALE_MS;
46
+ const WS_PING_ENABLED = PROVIDER_WS_PING_ENABLED;
39
47
 
40
48
  // WS socket pool buckets are keyed by `poolKey` (the per-call sessionId)
41
49
  // to isolate parallel agent invocations — each gets its own socket so
@@ -263,6 +271,10 @@ function _getPoolArr(poolKey) {
263
271
  }
264
272
 
265
273
  function _removeFromPool(poolKey, entry) {
274
+ // Always tear down per-entry timers so evicting a socket never leaks an
275
+ // idle-close or liveness-ping interval.
276
+ _clearIdle(entry);
277
+ _clearLiveness(entry);
266
278
  if (!poolKey) return;
267
279
  const arr = _wsPool.get(poolKey);
268
280
  if (!arr) return;
@@ -293,6 +305,70 @@ function _isOpen(entry) {
293
305
  return entry?.socket?.readyState === WebSocket.OPEN;
294
306
  }
295
307
 
308
+ function _clearLiveness(entry) {
309
+ if (entry?.pingTimer) {
310
+ clearInterval(entry.pingTimer);
311
+ entry.pingTimer = null;
312
+ }
313
+ }
314
+
315
+ // Force a dead/half-open socket out of the pool. close() alone can hang on a
316
+ // wedged socket, so follow with terminate() to guarantee FD release.
317
+ function _evictDead(poolKey, entry) {
318
+ try { entry.socket.close(1000, 'ws_liveness_dead'); } catch {}
319
+ try { entry.socket.terminate?.(); } catch {}
320
+ _removeFromPool(poolKey, entry);
321
+ }
322
+
323
+ // Send one ws-level ping and resolve true iff a pong lands within timeoutMs.
324
+ // Never rejects. On any success it refreshes lastAliveAt so the caller/loop
325
+ // treats the socket as fresh.
326
+ function _pingProbe(entry, timeoutMs) {
327
+ return new Promise((resolve) => {
328
+ const socket = entry?.socket;
329
+ if (!socket || socket.readyState !== WebSocket.OPEN) { resolve(false); return; }
330
+ let done = false;
331
+ const finish = (alive) => {
332
+ if (done) return;
333
+ done = true;
334
+ clearTimeout(timer);
335
+ try { socket.removeListener('pong', onPong); } catch {}
336
+ resolve(alive);
337
+ };
338
+ const onPong = () => { entry.lastAliveAt = Date.now(); finish(true); };
339
+ const timer = setTimeout(() => finish(false), timeoutMs);
340
+ try { timer.unref?.(); } catch {}
341
+ try {
342
+ socket.on('pong', onPong);
343
+ socket.ping();
344
+ } catch {
345
+ finish(false);
346
+ }
347
+ });
348
+ }
349
+
350
+ // While an entry sits idle in the pool, ping it every WS_PING_INTERVAL_MS.
351
+ // A missed pong (or a socket that is no longer OPEN) evicts the entry so it can
352
+ // never be handed out dead. Busy entries are skipped — an in-flight turn has
353
+ // its own inter-chunk/semantic-idle watchdogs.
354
+ function _armLiveness(poolKey, entry) {
355
+ _clearLiveness(entry);
356
+ entry.pingTimer = setInterval(async () => {
357
+ if (entry.busy || entry.closing || entry.probing) return;
358
+ if (!_isOpen(entry)) { _evictDead(poolKey, entry); return; }
359
+ // Recent activity ⇒ assume live, skip the probe this tick.
360
+ if (Date.now() - (entry.lastAliveAt || 0) < WS_LIVENESS_STALE_MS) return;
361
+ entry.probing = true;
362
+ try {
363
+ const alive = await _pingProbe(entry, WS_PONG_TIMEOUT_MS);
364
+ if (!alive && !entry.busy) _evictDead(poolKey, entry);
365
+ } finally {
366
+ entry.probing = false;
367
+ }
368
+ }, WS_PING_INTERVAL_MS);
369
+ try { entry.pingTimer.unref?.(); } catch {}
370
+ }
371
+
296
372
  // Awaited frame send. Asserts the socket is OPEN and resolves only after
297
373
  // the underlying transport reports the buffered write succeeded (or fails)
298
374
  // via the WebSocket send callback. Raw `socket.send(JSON.stringify(...))`
@@ -596,15 +672,36 @@ export async function acquireWebSocket({ auth, poolKey, cacheKey, codexHeaders,
596
672
  for (let i = arr.length - 1; i >= 0; i--) {
597
673
  if (!_isOpen(arr[i]) || arr[i].closing) {
598
674
  _clearIdle(arr[i]);
675
+ _clearLiveness(arr[i]);
599
676
  arr.splice(i, 1);
600
677
  }
601
678
  }
602
679
  if (arr.length === 0) _wsPool.delete(poolKey);
603
- // Reuse any idle open entry (cache-warm path).
604
- const idle = arr.find(e => !e.busy);
605
- if (idle) {
680
+ // Reuse an idle open entry (cache-warm path). An entry with no observed
681
+ // activity within the freshness window is ping-probed under a short
682
+ // bound before hand-out; a dead one is evicted and the scan retries the
683
+ // next idle entry so a busy caller is never handed a wedged socket.
684
+ let idle;
685
+ while ((idle = arr.find(e => !e.busy))) {
606
686
  _clearIdle(idle);
687
+ _clearLiveness(idle);
688
+ // Reserve the entry BEFORE awaiting the probe: _pingProbe yields the
689
+ // event loop, so without this a second concurrent acquire could scan
690
+ // the same still-idle entry and both would take it. Marking busy up
691
+ // front makes the find() above skip it; on probe failure it is
692
+ // evicted (removed from arr) so the loop continues cleanly.
607
693
  idle.busy = true;
694
+ if (WS_PING_ENABLED && Date.now() - (idle.lastAliveAt || 0) >= WS_LIVENESS_STALE_MS) {
695
+ const alive = await _pingProbe(idle, WS_PONG_TIMEOUT_MS);
696
+ if (!alive) {
697
+ if (process.env.MIXDOG_DEBUG_AGENT) {
698
+ process.stderr.write(`[agent-trace] acquire-evict-dead poolKey=${poolKey} reason=missed_pong elapsed=${Date.now() - _acqStart}ms\n`);
699
+ }
700
+ _evictDead(poolKey, idle);
701
+ continue;
702
+ }
703
+ }
704
+ idle.lastAliveAt = Date.now();
608
705
  // Defensive: pre-existing pooled entries created before the
609
706
  // prefix-hash field was introduced may not have it set. Normalize
610
707
  // to null so the first delta check reads a deterministic value
@@ -646,6 +743,11 @@ export async function acquireWebSocket({ auth, poolKey, cacheKey, codexHeaders,
646
743
  ephemeral: true,
647
744
  sessionToken: ephSessionToken,
648
745
  };
746
+ entry.lastAliveAt = Date.now();
747
+ entry.pingTimer = null;
748
+ entry.probing = false;
749
+ socket.on('pong', () => { entry.lastAliveAt = Date.now(); });
750
+ socket.on('message', () => { entry.lastAliveAt = Date.now(); });
649
751
  socket.on('close', () => { entry.closing = true; });
650
752
  return { entry, reused: false };
651
753
  }
@@ -674,6 +776,11 @@ export async function acquireWebSocket({ auth, poolKey, cacheKey, codexHeaders,
674
776
  ephemeral: false,
675
777
  sessionToken,
676
778
  };
779
+ entry.lastAliveAt = Date.now();
780
+ entry.pingTimer = null;
781
+ entry.probing = false;
782
+ socket.on('pong', () => { entry.lastAliveAt = Date.now(); });
783
+ socket.on('message', () => { entry.lastAliveAt = Date.now(); });
677
784
  if (poolKey && !forceFresh) _getPoolArr(poolKey).push(entry);
678
785
  socket.on('close', () => {
679
786
  entry.closing = true;
@@ -690,7 +797,12 @@ export function releaseWebSocket({ entry, poolKey, keep }) {
690
797
  _removeFromPool(poolKey, entry);
691
798
  return;
692
799
  }
800
+ // Mark activity at release, then arm both the idle-close timer and the
801
+ // periodic liveness ping so a socket that dies while pooled is evicted
802
+ // before the next acquire can hand it out.
803
+ entry.lastAliveAt = Date.now();
693
804
  _scheduleIdleClose(poolKey, entry);
805
+ if (WS_PING_ENABLED) _armLiveness(poolKey, entry);
694
806
  }
695
807
 
696
808
  // Drain-complete fence — set true once _closeAllPooledSockets runs so any
@@ -706,6 +818,10 @@ export function _closeAllPooledSockets(reason = 'shutdown') {
706
818
  _drainComplete = true;
707
819
  for (const arr of _wsPool.values()) {
708
820
  for (const entry of arr) {
821
+ // Tear down per-entry timers before dropping the map, otherwise the
822
+ // idle-close and liveness-ping intervals outlive the drained pool.
823
+ _clearIdle(entry);
824
+ _clearLiveness(entry);
709
825
  try { entry.socket.close(1000, reason); } catch {}
710
826
  }
711
827
  }