mixdog 0.9.36 → 0.9.37

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 (103) 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/explore-bench-tmp.mjs +19 -0
  5. package/scripts/explore-bench.mjs +36 -6
  6. package/scripts/explore-prompt-policy-test.mjs +27 -0
  7. package/scripts/ingest-pure-conversation-smoke.mjs +11 -11
  8. package/scripts/openai-ws-early-settle-test.mjs +176 -0
  9. package/scripts/output-style-smoke.mjs +17 -17
  10. package/scripts/provider-toolcall-test.mjs +333 -14
  11. package/scripts/recall-bench-cases.json +3 -3
  12. package/scripts/recall-bench.mjs +1 -1
  13. package/scripts/recall-quality-cases.json +4 -4
  14. package/scripts/recall-usecase-cases.json +2 -2
  15. package/scripts/session-bench.mjs +13 -13
  16. package/scripts/steering-drain-buckets-test.mjs +72 -11
  17. package/scripts/submit-commandbusy-race-test.mjs +114 -0
  18. package/scripts/tool-smoke.mjs +83 -10
  19. package/src/app.mjs +2 -1
  20. package/src/defaults/cycle3-review-prompt.md +9 -0
  21. package/src/defaults/skills/setup/SKILL.md +93 -293
  22. package/src/lib/rules-builder.cjs +3 -2
  23. package/src/output-styles/default.md +2 -2
  24. package/src/output-styles/extreme-minimal.md +1 -1
  25. package/src/output-styles/minimal.md +1 -1
  26. package/src/output-styles/simple.md +2 -3
  27. package/src/rules/agent/30-explorer.md +15 -7
  28. package/src/rules/lead/01-general.md +4 -0
  29. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +15 -3
  30. package/src/runtime/agent/orchestrator/config.mjs +1 -1
  31. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +6 -6
  32. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +6 -6
  33. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +35 -5
  34. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +27 -0
  35. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +36 -37
  36. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +2 -2
  37. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +72 -1
  38. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +26 -3
  39. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +12 -28
  40. package/src/runtime/agent/orchestrator/providers/openai-transport-policy.mjs +18 -15
  41. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +4 -2
  42. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +113 -2
  43. package/src/runtime/agent/orchestrator/providers/registry.mjs +44 -5
  44. package/src/runtime/agent/orchestrator/providers/tool-stream-state.mjs +78 -0
  45. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +57 -31
  46. package/src/runtime/agent/orchestrator/session/cache/scoped-cache.mjs +8 -6
  47. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +28 -2
  48. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +1 -3
  49. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +15 -2
  50. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +26 -17
  51. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +2 -2
  52. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +24 -16
  53. package/src/runtime/agent/orchestrator/session/result-classification.mjs +2 -0
  54. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +5 -2
  55. package/src/runtime/agent/orchestrator/stall-policy.mjs +18 -0
  56. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +35 -20
  57. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +7 -7
  58. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +77 -31
  59. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +25 -3
  60. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +9 -2
  61. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +73 -25
  62. package/src/runtime/agent/orchestrator/tools/builtin.mjs +2 -1
  63. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +17 -7
  64. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +14 -12
  65. package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +5 -0
  66. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +56 -6
  67. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +9 -0
  68. package/src/runtime/channels/lib/worker-main.mjs +5 -0
  69. package/src/runtime/memory/lib/core-memory-store.mjs +104 -0
  70. package/src/runtime/memory/lib/ko-morph.mjs +1 -1
  71. package/src/runtime/memory/lib/memory-cycle3.mjs +64 -4
  72. package/src/runtime/memory/lib/memory-text-utils.mjs +4 -4
  73. package/src/runtime/memory/lib/recall-format.mjs +3 -3
  74. package/src/runtime/shared/child-spawn-gate.mjs +15 -0
  75. package/src/runtime/shared/config.mjs +98 -12
  76. package/src/runtime/shared/process-listener-headroom.mjs +12 -0
  77. package/src/session-runtime/cwd-plugins.mjs +6 -3
  78. package/src/session-runtime/model-route-api.mjs +50 -2
  79. package/src/session-runtime/provider-auth-api.mjs +8 -1
  80. package/src/session-runtime/resource-api.mjs +22 -0
  81. package/src/session-runtime/runtime-core.mjs +13 -2
  82. package/src/session-runtime/settings-api.mjs +11 -2
  83. package/src/standalone/agent-tool.mjs +15 -9
  84. package/src/standalone/explore-tool.mjs +4 -1
  85. package/src/tui/App.jsx +6 -5
  86. package/src/tui/app/transcript-window.mjs +60 -10
  87. package/src/tui/app/use-transcript-window.mjs +2 -1
  88. package/src/tui/components/ContextPanel.jsx +4 -1
  89. package/src/tui/components/ToolExecution.jsx +15 -12
  90. package/src/tui/components/TranscriptItem.jsx +1 -1
  91. package/src/tui/components/tool-execution/surface-detail.mjs +8 -8
  92. package/src/tui/dist/index.mjs +467 -151
  93. package/src/tui/engine/agent-job-feed.mjs +26 -6
  94. package/src/tui/engine/notification-plan.mjs +3 -3
  95. package/src/tui/engine/render-timing.mjs +104 -8
  96. package/src/tui/engine/session-api.mjs +58 -3
  97. package/src/tui/engine/session-flow.mjs +78 -28
  98. package/src/tui/engine/tool-card-results.mjs +28 -13
  99. package/src/tui/engine/turn.mjs +232 -156
  100. package/src/tui/engine.mjs +17 -8
  101. package/src/tui/index.jsx +10 -1
  102. package/src/workflows/default/WORKFLOW.md +8 -4
  103. package/src/workflows/solo/WORKFLOW.md +8 -4
@@ -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,9 @@ 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 are dropped whenever
199
+ // previous_response_id is present because the server resolves them from the
200
+ // chained response and rejects sending both fields together.
200
201
  // Full/warmup frames pass the body unchanged and keep every key in place.
201
202
  // omitTransportFields is used by wire-parity/prewarm helpers to drop stream/background.
202
203
  export function _buildResponseCreateFrame(body, { previousResponseId = null, inputOverride, omitTransportFields = false } = {}) {
@@ -214,6 +215,7 @@ export function _buildResponseCreateFrame(body, { previousResponseId = null, inp
214
215
  for (const key of Object.keys(src)) {
215
216
  if (omitTransportFields && TRANSPORT_ONLY_FRAME_FIELDS.has(key)) continue;
216
217
  if (key === 'instructions') {
218
+ if (previousResponseId != null) continue;
217
219
  const instr = src.instructions;
218
220
  if (typeof instr === 'string' && instr.length) frame.instructions = instr;
219
221
  continue;
@@ -23,9 +23,9 @@ import { makeInvalidToolArgsMarker } from './openai-compat-stream.mjs';
23
23
  import { createLeakGuard, createToolCallDedupe, dedupeToolCallList } from './anthropic-leaked-toolcall.mjs';
24
24
  import {
25
25
  PROVIDER_WS_INTER_CHUNK_TIMEOUT_MS,
26
- PROVIDER_SEMANTIC_IDLE_TIMEOUT_MS,
27
26
  PROVIDER_SSE_IDLE_WATCHDOG_ENABLED,
28
27
  PROVIDER_WS_FIRST_MEANINGFUL_TIMEOUT_MS,
28
+ PROVIDER_WS_SEMANTIC_IDLE_TIMEOUT_MS,
29
29
  streamStalledError,
30
30
  } from '../stall-policy.mjs';
31
31
  import { customToolCallFromResponseItem } from './custom-tool-wire.mjs';
@@ -47,6 +47,10 @@ import {
47
47
  _isMaxOutputIncompleteReason,
48
48
  _httpStatusFromWsClose,
49
49
  } from './openai-ws-events.mjs';
50
+ import {
51
+ createActiveToolItemTracker,
52
+ hasCompleteToolCall as sharedHasCompleteToolCall,
53
+ } from './tool-stream-state.mjs';
50
54
 
51
55
  // Facade re-exports: the delta/matching helpers and usage/event helpers below
52
56
  // were extracted to openai-ws-delta.mjs / openai-ws-events.mjs (no behavior
@@ -258,10 +262,24 @@ export async function _streamResponse({
258
262
  const citations = [];
259
263
  const citationKeys = new Set();
260
264
  const pendingCalls = new Map();
265
+ // Active tool-item / alias tracking extracted to tool-stream-state.mjs.
266
+ // Keep the WS-local names bound to the tracker's Set + methods so the
267
+ // event-switch call sites (markActiveToolItem/clearActiveToolItem/
268
+ // activeToolItems.size) read unchanged.
269
+ const _toolTracker = createActiveToolItemTracker();
270
+ const activeToolItems = _toolTracker.items;
271
+ const markActiveToolItem = _toolTracker.mark;
272
+ const clearActiveToolItem = _toolTracker.clear;
261
273
  // Tool-work-in-flight flag: set the moment a function/custom tool call's
262
274
  // input starts streaming (before it lands in pendingCalls/toolCalls).
263
275
  // Gates partial-final SUCCESS so a stall mid tool-input never looks text-only.
264
276
  let _toolInFlight = false;
277
+ // Early tool-call settle latch. Set when a fully-formed tool call is
278
+ // available but the server never emits response.completed/response.done —
279
+ // we resolve successfully anyway and surface `closeSocket` so the pool
280
+ // discards (never reuses) the socket, which may still carry the missing
281
+ // terminal frames as orphans.
282
+ let _earlySettled = false;
265
283
  // Fix 2: cross-path name+args dedupe. A text-leaked synthetic and an
266
284
  // identical native function_call must fire onToolCall exactly once. Every
267
285
  // dispatch site routes through emitToolCallDedupe.
@@ -461,7 +479,13 @@ export async function _streamResponse({
461
479
  // only) trips a short, named terminal StreamStalledError instead of coasting
462
480
  // to the 300s inter-chunk cap / 30-min agent watchdog.
463
481
  let semanticIdleTimer = null;
464
- const semanticIdleMs = PROVIDER_SEMANTIC_IDLE_TIMEOUT_MS;
482
+ // WS gets its own shorter semantic-idle window. The shared SSE semantic
483
+ // timeout is intentionally near the 300s stall warning to protect Anthropic
484
+ // effort-mode "silent thinking"; using that value here lets a wedged WS
485
+ // request race the TUI 300s watchdog. Keep websocket stalls provider-owned:
486
+ // fail/retry/fallback here first, then let the TUI watchdog remain a final
487
+ // safety net only.
488
+ const semanticIdleMs = PROVIDER_WS_SEMANTIC_IDLE_TIMEOUT_MS;
465
489
  const semanticIdleEnabled = PROVIDER_SSE_IDLE_WATCHDOG_ENABLED && semanticIdleMs > 0;
466
490
  // First-meaningful-frame watchdog timer + one-shot latch. Armed alongside
467
491
  // the pre-stream watchdog; cleared exactly once by the first meaningful
@@ -575,6 +599,11 @@ export async function _streamResponse({
575
599
  const resetInterChunk = () => {
576
600
  if (interChunkTimer) clearTimeout(interChunkTimer);
577
601
  interChunkTimer = setTimeout(() => {
602
+ // Early settle: a complete tool call is already captured with no
603
+ // deferred salvage pending, so silence here is the missing
604
+ // response.completed/done — resolve with the tool call instead
605
+ // of a stall error the loop would retry.
606
+ if (settleEarlyOnToolCall('inter_chunk')) return;
578
607
  if (process.env.MIXDOG_DEBUG_AGENT) {
579
608
  process.stderr.write(`[agent-trace] ws-timeout kind=inter-chunk afterMs=${interChunkMs}\n`);
580
609
  }
@@ -593,6 +622,11 @@ export async function _streamResponse({
593
622
  if (!semanticIdleEnabled) return;
594
623
  if (semanticIdleTimer) clearTimeout(semanticIdleTimer);
595
624
  semanticIdleTimer = setTimeout(() => {
625
+ // Early settle before treating tool-work silence as a stall: if
626
+ // a complete tool call is available and no deferred salvage
627
+ // remains, the turn result is already final even without
628
+ // response.completed/done.
629
+ if (settleEarlyOnToolCall('semantic_idle')) return;
596
630
  traceWsTimeout('semantic_idle_timeout', semanticIdleMs);
597
631
  terminalError = streamStalledError('Responses WS', semanticIdleMs, { emittedToolCall: !!midState?.emittedToolCall });
598
632
  // Partial-final recovery: attach streamed partial state so
@@ -672,11 +706,43 @@ export async function _streamResponse({
672
706
  // for the WS path (sendViaWebSocket spreads this result
673
707
  // through to the provider caller unchanged).
674
708
  ...(stopReason === 'length' && content.length > 0 ? { truncated: true } : {}),
709
+ // Early tool-call settle: tell the caller to drop (not pool)
710
+ // this socket. The server may still emit the skipped
711
+ // response.completed/done as orphan frames; reusing the socket
712
+ // would read them as the next request's stream and wedge it.
713
+ ...(_earlySettled ? { closeSocket: true } : {}),
675
714
  incompleteReason: incompleteReason || undefined,
676
715
  responseId: responseId || undefined,
677
716
  serviceTier: responseServiceTier || undefined,
678
717
  });
679
718
  };
719
+ // Early tool-call settle guard. A fully-formed tool call (real call_id +
720
+ // name, not a deferred salvage placeholder) means the turn's actionable
721
+ // result is already complete. When the server then goes silent without
722
+ // response.completed/response.done, resolve successfully with that tool
723
+ // call rather than surfacing a stall error the loop gates out via
724
+ // pendingToolUse and retries. Returns true if it settled the stream.
725
+ const hasCompleteToolCall = () => sharedHasCompleteToolCall({
726
+ toolCalls,
727
+ pendingSize: pendingCalls.size,
728
+ activeSize: activeToolItems.size,
729
+ toolInFlight: _toolInFlight,
730
+ });
731
+ const settleEarlyOnToolCall = (reason) => {
732
+ if (done || terminalError) return false;
733
+ if (!hasCompleteToolCall()) return false;
734
+ _earlySettled = true;
735
+ // Treat as a normal completion for downstream state: we have the
736
+ // canonical tool call, only the terminal lifecycle frame is missing.
737
+ midState.sawCompleted = true;
738
+ midState.wsEarlySettle = reason;
739
+ done = true;
740
+ // Close and mark keep=false (via closeSocket in the resolve). The
741
+ // late/never response.completed cannot then leak onto a pooled reuse.
742
+ try { socket.close(4000, `early_settle_${reason}`); } catch {}
743
+ finish();
744
+ return true;
745
+ };
680
746
 
681
747
  messageHandler = (data) => {
682
748
  resetIdle();
@@ -759,14 +825,17 @@ export async function _streamResponse({
759
825
  break;
760
826
  case 'response.output_item.added':
761
827
  if (event.item?.type === 'function_call') {
828
+ markActiveToolItem(event.item);
762
829
  pendingCalls.set(event.item.id || '', {
763
830
  name: event.item.name || '',
764
831
  callId: event.item.call_id || '',
765
832
  });
766
833
  _toolInFlight = true;
767
834
  } else if (event.item?.type === 'custom_tool_call') {
835
+ markActiveToolItem(event.item);
768
836
  _toolInFlight = true;
769
837
  } else if (event.item?.type === 'tool_search_call') {
838
+ markActiveToolItem(event.item);
770
839
  // Mark tool_search in-flight at item-added time, same
771
840
  // as function_call/custom_tool_call above, so the
772
841
  // semantic-idle stall gate's pendingToolUse never
@@ -780,11 +849,13 @@ export async function _streamResponse({
780
849
  resetSemanticIdle();
781
850
  break;
782
851
  case 'response.function_call_arguments.delta':
852
+ markActiveToolItem(null, event.item_id);
783
853
  _toolInFlight = true;
784
854
  try { onStreamDelta?.(); } catch {}
785
855
  bumpSemanticIdle();
786
856
  break;
787
857
  case 'response.custom_tool_call_input.delta':
858
+ markActiveToolItem(null, event.item_id);
788
859
  _toolInFlight = true;
789
860
  try { onStreamDelta?.(); } catch {}
790
861
  bumpSemanticIdle();
@@ -819,6 +890,11 @@ export async function _streamResponse({
819
890
  const call = { id: pending.callId, name: pending.name, arguments: args };
820
891
  toolCalls.push(call);
821
892
  emitToolCallDedupe(call);
893
+ pendingCalls.delete(itemId);
894
+ // Keep the function item active until output_item.done:
895
+ // arguments.done completes args, but the lifecycle item
896
+ // itself may still be followed by item.done/final frames.
897
+ _toolInFlight = pendingCalls.size > 0 || activeToolItems.size > 0;
822
898
  } else {
823
899
  // Synthesizing a `tc_${Date.now()}` callId here would
824
900
  // make the next turn fail to match the model's
@@ -850,11 +926,46 @@ export async function _streamResponse({
850
926
  // server-side prompt cache prefix warm.
851
927
  if (event.item?.type === 'reasoning') pushReasoningItem(event.item);
852
928
  if (event.item?.type === 'web_search_call') pushWebSearchCall(event.item);
929
+ if (event.item?.type === 'function_call') {
930
+ const item = event.item;
931
+ const itemId = item.id || '';
932
+ const callId = item.call_id || '';
933
+ const name = item.name || '';
934
+ const argsText = typeof item.arguments === 'string' ? item.arguments : '';
935
+ let args = {};
936
+ if (argsText.trim() !== '') {
937
+ try {
938
+ args = JSON.parse(argsText);
939
+ } catch (err) {
940
+ args = makeInvalidToolArgsMarker(argsText, err instanceof Error ? err.message : String(err));
941
+ }
942
+ }
943
+ const deferred = toolCalls.find((tc) => tc?._deferred && (!itemId || tc._pendingItemId === itemId));
944
+ if (deferred && callId && name) {
945
+ deferred.id = callId;
946
+ deferred.name = name;
947
+ deferred.arguments = deferred.arguments && Object.keys(deferred.arguments).length > 0 ? deferred.arguments : args;
948
+ delete deferred._deferred;
949
+ delete deferred._pendingItemId;
950
+ emitToolCallDedupe(deferred);
951
+ } else if (callId && name && !toolCalls.some((tc) => tc?.id === callId)) {
952
+ const call = { id: callId, name, arguments: args };
953
+ toolCalls.push(call);
954
+ emitToolCallDedupe(call);
955
+ }
956
+ if (itemId) pendingCalls.delete(itemId);
957
+ clearActiveToolItem(item, itemId);
958
+ _toolInFlight = pendingCalls.size > 0 || activeToolItems.size > 0;
959
+ }
853
960
  if (event.item?.type === 'tool_search_call') {
854
961
  pushToolSearchCall(event.item);
962
+ clearActiveToolItem(event.item);
963
+ _toolInFlight = pendingCalls.size > 0 || activeToolItems.size > 0;
855
964
  }
856
965
  if (event.item?.type === 'custom_tool_call') {
857
966
  pushCustomToolCall(event.item);
967
+ clearActiveToolItem(event.item);
968
+ _toolInFlight = pendingCalls.size > 0 || activeToolItems.size > 0;
858
969
  }
859
970
  // Item-done is genuine lifecycle progress — reset semantic
860
971
  // idle so latency before the next item/args does not stall.
@@ -24,6 +24,15 @@ const signatures = new Map();
24
24
  // strictly sequential at the registry level, independent of any caller gating,
25
25
  // so two different config signatures can never interleave their rebuilds.
26
26
  let _initChain = Promise.resolve();
27
+ // Singleflight state layered on top of the serial chain. Under simultaneous
28
+ // multi-agent launch, many callers hit initProviders() with a byte-identical
29
+ // config. `_inFlightPromise`/`_inFlightSig` coalesce those onto the pending
30
+ // init instead of queueing redundant clear()+rebuild passes behind it, and
31
+ // `_lastAppliedSig` lets a repeat call short-circuit entirely once the chain
32
+ // is idle. Differing signatures still serialize through _initChain as before.
33
+ let _inFlightPromise = null;
34
+ let _inFlightSig = null;
35
+ let _lastAppliedSig = null;
27
36
 
28
37
  // Deterministic structural signature of a provider config. Recursively sorts
29
38
  // object keys so signature equality reflects config-value equality regardless
@@ -80,6 +89,16 @@ function instantiateProvider(name, Ctor, cfg) {
80
89
  }
81
90
 
82
91
  export async function initProviders(config) {
92
+ const sig = configSignature(config);
93
+ // Coalesce: an identical config is already mid-init — attach to it.
94
+ if (sig !== null && _inFlightPromise && _inFlightSig === sig) {
95
+ return _inFlightPromise;
96
+ }
97
+ // Fast path: chain idle and the live registry already reflects this exact
98
+ // config — nothing to tear down or rebuild.
99
+ if (sig !== null && !_inFlightPromise && _lastAppliedSig === sig) {
100
+ return;
101
+ }
83
102
  // Serialize ALL inits through a single chain so two different config
84
103
  // signatures can never run their clear()+rebuild concurrently, regardless
85
104
  // of caller-side gating (agent-tool gateOnPrior may release a queued init
@@ -87,7 +106,19 @@ export async function initProviders(config) {
87
106
  const run = () => _initProvidersUnsynchronized(config);
88
107
  const next = _initChain.then(run, run);
89
108
  _initChain = next.then(() => {}, () => {});
90
- return next;
109
+ const settle = () => {
110
+ if (_inFlightPromise === tracked) {
111
+ _inFlightPromise = null;
112
+ _inFlightSig = null;
113
+ }
114
+ };
115
+ const tracked = next.then(
116
+ (v) => { _lastAppliedSig = sig; settle(); return v; },
117
+ (err) => { settle(); throw err; },
118
+ );
119
+ _inFlightSig = sig;
120
+ _inFlightPromise = tracked;
121
+ return tracked;
91
122
  }
92
123
 
93
124
  async function _initProvidersUnsynchronized(config) {
@@ -243,18 +274,23 @@ export function warmupCatalogs() {
243
274
  // context metadata stays on its own 24h TTL. Fire-and-forget: never awaited,
244
275
  // per-provider failures logged to stderr like warmupCatalogs.
245
276
  export function refreshProviderCatalogsOnStartup() {
277
+ const pending = [];
246
278
  for (const [name, provider] of providers) {
247
279
  const refreshFn = typeof provider?._refreshModelCache === 'function'
248
280
  ? () => provider._refreshModelCache()
249
281
  : (typeof provider?.listModels === 'function' ? () => provider.listModels() : null);
250
282
  if (!refreshFn) continue;
251
- Promise.resolve()
283
+ pending.push(Promise.resolve()
252
284
  .then(() => refreshFn())
253
285
  .catch((err) => {
254
286
  const msg = err instanceof Error ? err.message : String(err);
255
287
  process.stderr.write(`[provider:${name}] startup catalog refresh failed: ${msg}\n`);
256
- });
288
+ }));
257
289
  }
290
+ // Returns a completion promise so callers can invalidate stale model
291
+ // caches once the fresh catalogs land. Still fire-and-forget: unawaited
292
+ // callers keep the previous nonblocking startup behavior.
293
+ return Promise.allSettled(pending);
258
294
  }
259
295
 
260
296
  // Force-refresh provider catalogs after an operator changes model/provider
@@ -262,6 +298,7 @@ export function refreshProviderCatalogsOnStartup() {
262
298
  // the shared LiteLLM metadata cache first so context/pricing metadata follows
263
299
  // newly released models without waiting for the next process restart.
264
300
  export function refreshCatalogs() {
301
+ const pending = [];
265
302
  const metadataReady = Promise.resolve()
266
303
  .then(() => refreshMetadataCatalog())
267
304
  .catch((err) => {
@@ -274,12 +311,14 @@ export function refreshCatalogs() {
274
311
  ? () => provider._refreshModelCache()
275
312
  : (typeof provider?.listModels === 'function' ? () => provider.listModels() : null);
276
313
  if (!refreshFn) continue;
277
- Promise.resolve()
314
+ pending.push(Promise.resolve()
278
315
  .then(() => metadataReady)
279
316
  .then(() => refreshFn())
280
317
  .catch((err) => {
281
318
  const msg = err instanceof Error ? err.message : String(err);
282
319
  process.stderr.write(`[provider:${name}] catalog refresh failed: ${msg}\n`);
283
- });
320
+ }));
284
321
  }
322
+ // Completion promise: lets callers drop stale model caches after refresh.
323
+ return Promise.allSettled([metadataReady, ...pending]);
285
324
  }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * tool-stream-state.mjs — shared active tool-item / alias tracking and the
3
+ * complete-tool early-settle predicate for the OpenAI Responses stream
4
+ * consumers.
5
+ *
6
+ * Extracted verbatim (no behavior change) from openai-ws-stream.mjs, where the
7
+ * activeToolItems Set + activeToolAliases Map (id/call_id/fallback-key alias
8
+ * union) and the hasCompleteToolCall gate were WS-local closures. Kept as a
9
+ * plain factory + pure predicate so any Responses-shaped stream can reuse the
10
+ * same lifecycle-tracking semantics.
11
+ */
12
+
13
+ /**
14
+ * Per-stream active tool-item tracker. Tracks in-flight function/custom/
15
+ * tool_search items by every key they surface under (id, call_id, and any
16
+ * fallback item_id from delta frames), unioning aliases so a mark under one key
17
+ * and a clear under another still resolve to the same item.
18
+ */
19
+ export function createActiveToolItemTracker() {
20
+ const activeToolItems = new Set();
21
+ const activeToolAliases = new Map();
22
+ const activeToolKeys = (item, fallback = '') => {
23
+ const keys = [];
24
+ const add = (value) => {
25
+ const key = String(value || '');
26
+ if (key && !keys.includes(key)) keys.push(key);
27
+ };
28
+ add(fallback);
29
+ add(item?.id);
30
+ add(item?.call_id);
31
+ return keys;
32
+ };
33
+ const mark = (item, fallback = '') => {
34
+ const keys = new Set(activeToolKeys(item, fallback));
35
+ for (const key of [...keys]) {
36
+ const aliases = activeToolAliases.get(key);
37
+ if (aliases) for (const alias of aliases) keys.add(alias);
38
+ }
39
+ for (const key of keys) {
40
+ activeToolItems.add(key);
41
+ activeToolAliases.set(key, new Set(keys));
42
+ }
43
+ };
44
+ const clear = (item, fallback = '') => {
45
+ const keys = new Set(activeToolKeys(item, fallback));
46
+ for (const key of [...keys]) {
47
+ const aliases = activeToolAliases.get(key);
48
+ if (aliases) for (const alias of aliases) keys.add(alias);
49
+ }
50
+ for (const key of keys) {
51
+ activeToolItems.delete(key);
52
+ activeToolAliases.delete(key);
53
+ }
54
+ };
55
+ return {
56
+ items: activeToolItems,
57
+ aliases: activeToolAliases,
58
+ keys: activeToolKeys,
59
+ mark,
60
+ clear,
61
+ };
62
+ }
63
+
64
+ /**
65
+ * Early tool-call settle predicate. True only when a fully-formed tool call
66
+ * (real id + name, not a deferred salvage placeholder) is captured and no tool
67
+ * work is still in flight — pendingCalls drained, no active lifecycle item, and
68
+ * the in-flight latch cleared. Callers pass the current sizes/flag rather than
69
+ * the live collections so the check stays a pure snapshot.
70
+ */
71
+ export function hasCompleteToolCall({ toolCalls, pendingSize, activeSize, toolInFlight }) {
72
+ return toolCalls.length > 0
73
+ && pendingSize === 0
74
+ && activeSize === 0
75
+ && toolInFlight !== true
76
+ && !toolCalls.some((t) => t && t._deferred)
77
+ && toolCalls.every((t) => t && t.id && t.name);
78
+ }
@@ -195,21 +195,32 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
195
195
  const drainSteeringIntoMessages = (stage = 'mid-turn', options = {}) => {
196
196
  if (typeof opts.drainSteering !== 'function') return false;
197
197
  let steerMsgs = [];
198
- try { steerMsgs = opts.drainSteering(sessionId) || []; }
198
+ try { steerMsgs = opts.drainSteering(sessionId, options) || []; }
199
199
  catch { steerMsgs = []; }
200
- const merged = mergeSteeringEntries(steerMsgs);
201
- if (!merged) return false;
200
+ const mergedMessages = [];
201
+ for (const entry of Array.isArray(steerMsgs) ? steerMsgs : []) {
202
+ const merged = mergeSteeringEntries([entry]);
203
+ if (merged) mergedMessages.push(merged);
204
+ }
205
+ if (mergedMessages.length === 0) return false;
202
206
  if (typeof options.beforeAppend === 'function') {
203
207
  try { options.beforeAppend(); } catch { /* best-effort hook */ }
204
208
  }
205
- // Tag steering-origin user messages so provider lowering keeps them a
206
- // distinct user turn (see anthropic-oauth foldUserTextIntoToolResultTail):
207
- // human/TUI steering must stay attributed as user input, never folded
208
- // into a preceding tool_result where it reads as tool output.
209
- messages.push({ role: 'user', content: merged.content, meta: { source: 'steering' } });
210
- try { opts.onSteerMessage?.(merged.text || steeringContentText(merged.content)); } catch {}
209
+ let totalCount = 0;
210
+ let totalTextLen = 0;
211
+ for (const merged of mergedMessages) {
212
+ // Tag steering-origin user messages so provider lowering keeps them
213
+ // distinct from preceding tool results. Keep each queued command as
214
+ // its own user turn, matching Claude Code queued_command attachment
215
+ // semantics instead of collapsing priority/mode buckets together.
216
+ messages.push({ role: 'user', content: merged.content, meta: { source: 'steering' } });
217
+ const text = merged.text || steeringContentText(merged.content);
218
+ totalCount += Number(merged.count) || 1;
219
+ totalTextLen += String(text || '').length;
220
+ try { opts.onSteerMessage?.(text); } catch {}
221
+ }
211
222
  if (sessionId) {
212
- try { process.stderr.write(`[steer] sess=${sessionId} injected ${stage} user message (merged=${merged.count} len=${String(merged.text || '').length})\n`); } catch {}
223
+ try { process.stderr.write(`[steer] sess=${sessionId} injected ${stage} user message(s) (merged=${totalCount} len=${totalTextLen})\n`); } catch {}
213
224
  }
214
225
  return true;
215
226
  };
@@ -259,6 +270,15 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
259
270
  // the loop as an explicit empty termination instead.
260
271
  let _emptyNudgeStreak = 0;
261
272
  const EMPTY_NUDGE_MAX = 3;
273
+ // Claude Code parity: queued prompt/task notifications are attached after a
274
+ // tool batch, before the continuation provider send. Normal batches drain
275
+ // up to 'next'; a Sleep-like tool grants a 'later' flush.
276
+ let _toolBatchJustCompleted = false;
277
+ let _lastToolBatchHadSleep = false;
278
+ const isSleepLikeToolCall = (call) => {
279
+ const name = String(call?.name || call?.toolName || call?.function?.name || '').toLowerCase();
280
+ return name === 'sleep' || name.endsWith('/sleep') || name.endsWith('.sleep');
281
+ };
262
282
  // Completion-first steering ladder controller. Owns the (cumulative) level-1
263
283
  // fire count, the all-read-only / serial-single / same-file-grep streaks,
264
284
  // and the level-2 latch. Threaded via live getters so it reads the loop's
@@ -336,11 +356,19 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
336
356
  });
337
357
  } catch { /* best-effort */ }
338
358
  }
339
- // Drain queued steering/prompts BEFORE the
340
- // pre-send compact check. The compact decision must see the exact
341
- // message set that the next provider.send would receive, including
342
- // tool results plus any queued user input/notifications.
343
- drainSteeringIntoMessages('pre-send');
359
+ // Drain queued steering/prompts BEFORE the pre-send compact check, but
360
+ // only immediately after a tool batch has completed. This mirrors
361
+ // Claude Code's query.ts queued_command attachment drain: queued entries
362
+ // are attached after tool results are appended and before the recursive
363
+ // continuation, not on arbitrary non-tool continuations (empty nudges,
364
+ // iteration-cap final text turns, etc.).
365
+ if (_toolBatchJustCompleted) {
366
+ drainSteeringIntoMessages('pre-send', {
367
+ maxPriority: _lastToolBatchHadSleep ? 'later' : 'next',
368
+ });
369
+ _toolBatchJustCompleted = false;
370
+ _lastToolBatchHadSleep = false;
371
+ }
344
372
  ({
345
373
  iterations,
346
374
  lastUsage,
@@ -442,16 +470,23 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
442
470
  // the stateless contract for providers that don't use continuation).
443
471
  providerState = response?.providerState ?? undefined;
444
472
  iterations = nextIteration;
445
- // Payload byte estimate serializes the FULL messages+tools array
446
- // only pay that cost when verbose loop tracing is actually enabled
447
- // (traceAgentLoop is a no-op otherwise).
448
- if (process.env.MIXDOG_AGENT_TRACE_VERBOSE === '1') {
473
+ // Loop trace has two modes (both no-op on provider behavior):
474
+ // VERBOSE=1 full row; pay the FULL messages+tools payload byte
475
+ // estimate (serializes the whole array).
476
+ // TIMING=1 → send-latency attribution only; skip the payload
477
+ // estimate so measuring send_ms does not itself add
478
+ // serialization cost during high-fanout bench runs.
479
+ const _traceVerbose = process.env.MIXDOG_AGENT_TRACE_VERBOSE === '1';
480
+ if (_traceVerbose || process.env.MIXDOG_AGENT_TRACE_TIMING === '1') {
449
481
  traceAgentLoop({
450
482
  sessionId,
451
483
  iteration: iterations,
452
484
  sendMs: Date.now() - sendStartedAt,
453
485
  messageCount: Array.isArray(messages) ? messages.length : 0,
454
- bodyBytesEst: estimateProviderPayloadBytes(messages, model, sendTools),
486
+ bodyBytesEst: _traceVerbose
487
+ ? estimateProviderPayloadBytes(messages, model, sendTools)
488
+ : undefined,
489
+ agent: sessionAgent || null,
455
490
  });
456
491
  }
457
492
  // Accumulate usage across iterations — every billable slot, not just
@@ -556,17 +591,6 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
556
591
  const isHidden = HIDDEN_AGENT_NAMES.has(sessionAgent);
557
592
  const stopReason = response.stopReason ?? response.stop_reason ?? null;
558
593
  const isIncompleteStop = stopReason && INCOMPLETE_STOP_REASONS.has(stopReason);
559
- // A user/schedule notification can arrive while provider.send() is
560
- // returning a terminal no-tool response. Drain once before accepting
561
- // it as final so the queued input is handled in the same active turn
562
- // instead of waiting for post-turn TUI drain. If the model already
563
- // produced assistant text, persist that as an intermediate assistant
564
- // message before appending the steered user message.
565
- if (drainSteeringIntoMessages('final-pre-send', {
566
- beforeAppend: () => pushIntermediateAssistantResponse(response),
567
- })) {
568
- continue;
569
- }
570
594
  if (!hasContent && !isHidden) {
571
595
  _emptyNudgeStreak += 1;
572
596
  if (_emptyNudgeStreak > EMPTY_NUDGE_MAX) {
@@ -662,6 +686,8 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
662
686
  pushToolResultMessage, throwIfAborted,
663
687
  repeatFailLimit: REPEAT_FAIL_LIMIT,
664
688
  }));
689
+ _toolBatchJustCompleted = true;
690
+ _lastToolBatchHadSleep = calls.some(isSleepLikeToolCall);
665
691
  }
666
692
  // Classify WHY the loop ended so agent-tool can promote an empty/abnormal
667
693
  // finish to an explicit Lead-facing error instead of a silent empty