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
@@ -16,9 +16,13 @@
16
16
  error origin, ...) and send them as ONE `query[]` call — facets run in
17
17
  parallel. Never fan out rephrasings of the same target; on
18
18
  EXPLORATION_FAILED, retry once with changed tokens.
19
- - Verified paths = user-provided or tool-returned. Guessed/unknown path
20
- `find`/`glob`/`explore` first; ENOENT `find` basename, never retry a guess
21
- or invent absolute paths outside the project.
19
+ - Verified = user-provided, tool-returned, or the session project cwd itself.
20
+ Unscoped grep/glob/list from the project root needs NO find hop; find only
21
+ resolves a genuinely guessed path-name fragment, and it rides the SAME turn
22
+ as independent probes (unscoped grep, code_graph) — never a solo
23
+ path-verification turn. ENOENT → `find` basename, never retry a guess; don't
24
+ mask a miss with a guessed glob scope (path "." + `src/**`) or an invented
25
+ absolute path, but plain project-root searches are fine.
22
26
  - Retrieval stops when evidence covers the deliverable: single-answer tasks
23
27
  end at the first sufficient anchor; enumeration tasks (review, audit) end
24
28
  when the stated scope is covered. Never re-verify a hit already on screen;
@@ -21,15 +21,22 @@ const MIXDOG_SLOW_TOOL_TRACE_NAMES = new Set(
21
21
  .filter(Boolean)
22
22
  );
23
23
 
24
- function traceAgentLoop({ sessionId, iteration, sendMs, messageCount, bodyBytesEst }) {
25
- if (process.env.MIXDOG_AGENT_TRACE_VERBOSE !== '1') return;
24
+ function traceAgentLoop({ sessionId, iteration, sendMs, messageCount, bodyBytesEst, agent = null }) {
25
+ // Two emit modes, no behavior change either way:
26
+ // VERBOSE=1 → full loop row incl. body_bytes_est (payload serialized).
27
+ // TIMING=1 → lightweight send-latency attribution for high-fanout
28
+ // benches; bodyBytesEst is skipped upstream so measuring
29
+ // the send does not perturb it (body_bytes_est → null).
30
+ if (process.env.MIXDOG_AGENT_TRACE_VERBOSE !== '1'
31
+ && process.env.MIXDOG_AGENT_TRACE_TIMING !== '1') return;
26
32
  appendAgentTrace({
27
33
  sessionId,
28
34
  iteration,
29
35
  kind: 'loop',
36
+ agent: agent || null,
30
37
  send_ms: sendMs,
31
38
  message_count: messageCount,
32
- body_bytes_est: bodyBytesEst,
39
+ body_bytes_est: bodyBytesEst ?? null,
33
40
  });
34
41
  }
35
42
 
@@ -206,6 +213,11 @@ function _redactLogText(text) {
206
213
 
207
214
  function classifyToolFailure(resultText, toolName) {
208
215
  const text = String(resultText ?? '').toLowerCase();
216
+ if (/\[shell-tool-failed\]/i.test(String(resultText ?? ''))) return 'tool-call/failure';
217
+ if (/\[shell-run-failed\]/i.test(String(resultText ?? ''))) {
218
+ if (/timeout|timed out|aborted|interrupted/.test(text)) return 'timeout/abort';
219
+ return 'command-exit';
220
+ }
209
221
  if (/requires either|invalid arguments|unknown parameter|must be|schema|expected|required|old_string is .*>=/.test(text)) return 'schema/args';
210
222
  if (/not in allow-list|not allowed/.test(text)) return 'permission';
211
223
  if (String(toolName || '') === 'shell' || /^\s*\[exit code:\s*\d+\]/i.test(String(resultText ?? ''))) return 'command-exit';
@@ -46,7 +46,7 @@ export const MAINTENANCE_SLOTS = Object.freeze(['explore', 'memory']);
46
46
  export const PROFILE_LANGUAGES = Object.freeze([
47
47
  { id: 'system', label: 'System (locale)', prompt: null },
48
48
  { id: 'en', label: 'English', prompt: 'English' },
49
- { id: 'ko', label: '한국어', prompt: 'Korean (한국어)' },
49
+ { id: 'ko', label: 'Korean', prompt: 'Korean' },
50
50
  { id: 'ja', label: '日本語', prompt: 'Japanese (日本語)' },
51
51
  { id: 'zh-Hans', label: '中文(简体)', prompt: 'Simplified Chinese (简体中文)' },
52
52
  { id: 'zh-Hant', label: '中文(繁體)', prompt: 'Traditional Chinese (繁體中文)' },
@@ -372,6 +372,27 @@ export function recoverPending(dataDir, notifyFn, { sessionId, priorSessionId, c
372
372
  const entry = map[handle] || {};
373
373
  const tool = entry.tool || 'dispatch';
374
374
  const queries = Array.isArray(entry.queries) ? entry.queries : [];
375
+ // Determine the true owner session for this entry. A scoped recovery
376
+ // may have matched purely on clientHostPid (not on the owner session
377
+ // id); in that case we must NOT stamp the reconnecting filter session's
378
+ // id onto another session's abort — that injects an old-session abort
379
+ // into the wrong resumed session. Deliver to the true owner session, or
380
+ // leave the entry persisted when it carries no owner session to target.
381
+ const cid = entry.callerSessionId != null && String(entry.callerSessionId)
382
+ ? String(entry.callerSessionId)
383
+ : null;
384
+ const ownerMatch = cid != null && (cid === filterSid || (priorSid != null && cid === priorSid));
385
+ if (scoped && !ownerMatch && cid == null) {
386
+ // hostPid-only match with no owner session id — cannot target a
387
+ // session safely. Leave persisted for a correctly-scoped recovery.
388
+ continue;
389
+ }
390
+ // Owner match → prefer the reconnecting filter session id (the owner's
391
+ // new session). When only priorSessionId matched and no current
392
+ // sessionId was supplied, filterSid is null — keep the entry's known
393
+ // owner `cid` for stamping/ack scoping rather than dropping it.
394
+ // Non-owner matches (hostPid-only) always stamp the entry's true owner.
395
+ const stampSid = (ownerMatch && filterSid) ? filterSid : cid;
375
396
  // Single recovery mode: the worker was in flight at restart. Emit the
376
397
  // Aborted boilerplate so the Lead can retry. Completed result bodies are
377
398
  // never persisted, so there is nothing to replay here.
@@ -383,21 +404,23 @@ export function recoverPending(dataDir, notifyFn, { sessionId, priorSessionId, c
383
404
  dispatch_id: handle,
384
405
  tool,
385
406
  error: String(isError),
386
- ...(filterSid
387
- ? { caller_session_id: filterSid }
388
- : (entry.callerSessionId ? { caller_session_id: entry.callerSessionId } : {})),
407
+ ...(stampSid ? { caller_session_id: stampSid } : {}),
389
408
  ...(filterHostPid > 0
390
409
  ? { client_host_pid: String(filterHostPid) }
391
410
  : (entry.clientHostPid > 0 ? { client_host_pid: String(entry.clientHostPid) } : {})),
392
411
  instruction: `Earlier ${tool} dispatch (${handle}) was aborted by a plugin restart. Retry if the answer is still needed.`,
393
412
  };
394
413
  try { process.stderr.write(`[dispatch-persist] recover handle=${handle} tool=${tool} kind=abort\n`); } catch { /* best-effort */ }
395
- // Entry remains on disk until notifyFn resolves. A crash between
396
- // fire and ack is safe: the entry survives and recoverPending re-fires
397
- // it on the next restart. Only clear AFTER ack to prevent silent loss.
414
+ // Entry remains on disk until notifyFn settles as DELIVERED. Matching
415
+ // notifyToolCompletion settlement semantics (tool-execution-contract),
416
+ // only an explicit `false`/`0` resolve counts as undelivered and keeps
417
+ // the entry for retry; any other resolve (including `undefined`/void
418
+ // from a delivered notifyFn) removes it — otherwise it re-fires until
419
+ // TTL. A crash between fire and ack is likewise safe: the entry survives
420
+ // and recoverPending re-fires it on the next restart.
398
421
  try {
399
- Promise.resolve(notifyFn(content, meta)).then(() => {
400
- removePending(dataDir, handle);
422
+ Promise.resolve(notifyFn(content, meta)).then((ok) => {
423
+ if (ok !== false && ok !== 0) removePending(dataDir, handle);
401
424
  }).catch(() => { /* best-effort — entry stays for next recoverPending */ });
402
425
  } catch { /* best-effort */ }
403
426
  }
@@ -411,6 +411,14 @@ export async function beginOAuthLogin() {
411
411
  };
412
412
  const url = buildUrl(OAUTH_REDIRECT_URI);
413
413
  const manualUrl = buildUrl(OAUTH_MANUAL_REDIRECT_URI);
414
+ const openLoginUrl = async (targetUrl, label = 'login') => {
415
+ try {
416
+ const { openInBrowser } = await import('../../../shared/open-url.mjs');
417
+ openInBrowser(targetUrl.toString());
418
+ } catch (err) {
419
+ process.stderr.write(`[anthropic-oauth] browser open failed for ${label} URL: ${String(err?.message || err).slice(0, 200)}\n`);
420
+ }
421
+ };
414
422
 
415
423
  let server = null;
416
424
  let timeout = null;
@@ -454,14 +462,12 @@ export async function beginOAuthLogin() {
454
462
  timeout = setTimeout(() => finish(null), OAUTH_LOGIN_TIMEOUT_MS);
455
463
  server.listen(OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_HOST, async () => {
456
464
  process.stderr.write(`\n[anthropic-oauth] Open this URL to log in with Claude:\n${url.toString()}\n\nIf the localhost callback cannot complete, open this manual URL and paste the shown code#state:\n${manualUrl.toString()}\n\n`);
457
- try {
458
- const { openInBrowser } = await import('../../../shared/open-url.mjs');
459
- openInBrowser(url.toString());
460
- } catch (err) {
461
- process.stderr.write(`[anthropic-oauth] browser open failed: ${String(err?.message || err).slice(0, 200)}\n`);
462
- }
465
+ await openLoginUrl(url, 'callback');
466
+ });
467
+ server.on('error', async (err) => {
468
+ process.stderr.write(`\n[anthropic-oauth] localhost callback unavailable on ${OAUTH_CALLBACK_HOST}:${OAUTH_CALLBACK_PORT}: ${err?.message || err}\n[anthropic-oauth] Opening manual login URL instead. Paste the shown code#state:\n${manualUrl.toString()}\n\n`);
469
+ await openLoginUrl(manualUrl, 'manual');
463
470
  });
464
- server.on('error', (err) => finish(null, new Error(`[anthropic-oauth] callback server failed on ${OAUTH_CALLBACK_HOST}:${OAUTH_CALLBACK_PORT}: ${err?.message || err}`)));
465
471
  });
466
472
 
467
473
  return {
@@ -373,15 +373,15 @@ function toAnthropicMessages(messages) {
373
373
 
374
374
  if (m.role === 'tool') {
375
375
  const last = result[result.length - 1];
376
- const refs = Array.isArray(m.nativeToolSearch?.toolReferences)
377
- ? m.nativeToolSearch.toolReferences.map((name) => String(name || '').trim()).filter(Boolean)
378
- : [];
376
+ // Do not replay native deferred-loader `tool_reference` blocks from
377
+ // prior turns. They are tied to the exact Anthropic deferred-tool
378
+ // request that produced them; after /model or provider switches the
379
+ // new request may not carry the matching deferred tool surface and
380
+ // Anthropic rejects the history as a request validation error.
379
381
  const block = {
380
382
  type: 'tool_result',
381
383
  tool_use_id: m.toolCallId || '',
382
- content: refs.length
383
- ? refs.map((name) => ({ type: 'tool_reference', tool_name: name }))
384
- : normalizeContentForAnthropic(m.content),
384
+ content: normalizeContentForAnthropic(m.content),
385
385
  };
386
386
  if (last?.role === 'user' && Array.isArray(last.content)) {
387
387
  last.content.push(block);
@@ -376,15 +376,15 @@ function toAnthropicMessages(messages) {
376
376
  }
377
377
  if (m.role === 'tool') {
378
378
  const last = result[result.length - 1];
379
- const refs = Array.isArray(m.nativeToolSearch?.toolReferences)
380
- ? m.nativeToolSearch.toolReferences.map((name) => String(name || '').trim()).filter(Boolean)
381
- : [];
379
+ // Do not replay native deferred-loader `tool_reference` blocks from
380
+ // prior turns. They are tied to the exact Anthropic deferred-tool
381
+ // request that produced them; after /model or provider switches the
382
+ // new request may not carry the matching deferred tool surface and
383
+ // Anthropic rejects the history as a request validation error.
382
384
  const block = {
383
385
  type: 'tool_result',
384
386
  tool_use_id: m.toolCallId || '',
385
- content: refs.length
386
- ? refs.map((name) => ({ type: 'tool_reference', tool_name: name }))
387
- : normalizeContentForAnthropic(m.content),
387
+ content: normalizeContentForAnthropic(m.content),
388
388
  };
389
389
  if (last?.role === 'user' && Array.isArray(last.content)) {
390
390
  last.content.push(block);
@@ -15,31 +15,61 @@ function pushUnique(list, value) {
15
15
  if (!list.includes(value)) list.push(value);
16
16
  }
17
17
 
18
+ // Short-TTL memo. Under simultaneous multi-agent launch, buildDefaultConfig
19
+ // and registry lazy-init call these probes in bursts; each probe does
20
+ // synchronous existsSync + readFileSync disk work. Cache the boolean per key
21
+ // for a brief window so one launch burst shares a single read instead of N.
22
+ // Kept short so the registry's "re-probe on each getProvider miss" self-heal
23
+ // (a credential file appearing/changing) is delayed by at most PROBE_TTL_MS.
24
+ const PROBE_TTL_MS = 3000;
25
+ const _probeCache = new Map();
26
+ function memoProbe(key, compute) {
27
+ const hit = _probeCache.get(key);
28
+ const now = Date.now();
29
+ if (hit && now - hit.ts < PROBE_TTL_MS) return hit.value;
30
+ const value = compute();
31
+ _probeCache.set(key, { ts: now, value });
32
+ return value;
33
+ }
34
+
18
35
  export function hasAnthropicOAuthCredentials() {
36
+ return memoProbe('anthropic-oauth', () => {
19
37
  const paths = [];
38
+ const candidates = [];
20
39
  pushUnique(paths, process.env.ANTHROPIC_OAUTH_CREDENTIALS_PATH);
21
40
  pushUnique(paths, ANTHROPIC_DEFAULT_CREDENTIALS_PATH);
22
41
  for (const path of paths) {
23
42
  const raw = readJsonIfExists(path);
24
43
  const oauth = raw?.claudeAiOauth;
25
- if (oauth?.accessToken && Array.isArray(oauth.scopes) && oauth.scopes.includes('user:inference')) {
26
- return true;
44
+ if (oauth?.accessToken) {
45
+ candidates.push({
46
+ accessToken: oauth.accessToken,
47
+ expiresAt: Number(oauth.expiresAt ?? oauth.expires_at) || 0,
48
+ scopes: Array.isArray(oauth.scopes) ? oauth.scopes : [],
49
+ });
27
50
  }
28
51
  }
29
- return false;
52
+ if (!candidates.length) return false;
53
+ candidates.sort((a, b) => (Number(b.expiresAt) || 0) - (Number(a.expiresAt) || 0));
54
+ const chosen = candidates[0];
55
+ return !!(chosen.accessToken && Array.isArray(chosen.scopes) && chosen.scopes.includes('user:inference'));
56
+ });
30
57
  }
31
58
 
32
59
  export function hasOpenAIOAuthCredentials() {
60
+ return memoProbe('openai-oauth', () => {
33
61
  const paths = [join(resolvePluginData(), 'openai-oauth.json')];
34
62
  for (const path of paths) {
35
63
  const raw = readJsonIfExists(path);
36
- const tokens = raw?.tokens || raw;
37
- if (tokens?.access_token && tokens?.refresh_token) return true;
64
+ if (raw?.access_token && raw?.refresh_token) return true;
38
65
  }
39
66
  return false;
67
+ });
40
68
  }
41
69
 
42
70
  export function hasGrokOAuthCredentials() {
71
+ return memoProbe('grok-oauth', () => {
43
72
  const own = readJsonIfExists(join(resolvePluginData(), 'grok-oauth.json'));
44
73
  return !!(own?.access_token && own?.refresh_token);
74
+ });
45
75
  }
@@ -10,6 +10,7 @@ import { populateHttpStatusFromMessage } from './retry-classifier.mjs';
10
10
  import { customToolCallFromResponseItem } from './custom-tool-wire.mjs';
11
11
  import { createLeakGuard, createToolCallDedupe, dedupeToolCallList } from './anthropic-leaked-toolcall.mjs';
12
12
  import { randomBytes } from 'crypto';
13
+ import { createActiveToolItemTracker } from './tool-stream-state.mjs';
13
14
 
14
15
  // Synthesize a native-shaped OpenAI tool call from a recovered leaked call.
15
16
  // Matches the `call_...` id scheme the native Responses/Chat paths use so the
@@ -554,14 +555,17 @@ function handleCompatResponsesStreamEvent(event, state, { label, parseResponsesT
554
555
  name: event.item.name || '',
555
556
  callId: event.item.call_id || '',
556
557
  });
558
+ state.toolTracker?.mark(event.item);
557
559
  state.toolInFlight = true;
558
560
  } else if (event.item?.type === 'custom_tool_call') {
561
+ state.toolTracker?.mark(event.item);
559
562
  state.toolInFlight = true;
560
563
  } else if (event.item?.type === 'tool_search_call') {
561
564
  // Mark tool_search in-flight at item-added time, same as
562
565
  // function_call/custom_tool_call above, so the stall-recovery
563
566
  // pendingToolUse gate never drops a mid-flight tool_search
564
567
  // before response.output_item.done pushes it.
568
+ state.toolTracker?.mark(event.item);
565
569
  state.toolInFlight = true;
566
570
  }
567
571
  try { onStreamDelta?.(); } catch {}
@@ -569,6 +573,7 @@ function handleCompatResponsesStreamEvent(event, state, { label, parseResponsesT
569
573
  case 'response.function_call_arguments.delta':
570
574
  // A tool call's args are streaming — mark tool work in-flight so a
571
575
  // mid-args stall is NEVER accepted as a text-only partial-final.
576
+ state.toolTracker?.mark(null, event.item_id);
572
577
  state.toolInFlight = true;
573
578
  try { onStreamDelta?.(); } catch {}
574
579
  break;
@@ -576,6 +581,7 @@ function handleCompatResponsesStreamEvent(event, state, { label, parseResponsesT
576
581
  // Custom-tool input streams before output_item.done records the call
577
582
  // in pendingCalls; flag it so a mid-input stall gates out partial-
578
583
  // final success (otherwise a tool-bearing turn looks text-only).
584
+ state.toolTracker?.mark(null, event.item_id);
579
585
  state.toolInFlight = true;
580
586
  try { onStreamDelta?.(); } catch {}
581
587
  break;
@@ -616,10 +622,21 @@ function handleCompatResponsesStreamEvent(event, state, { label, parseResponsesT
616
622
  state.toolCalls.push(call);
617
623
  emitCompatToolCallOnce(state, call, onToolCall);
618
624
  }
625
+ // Drop the resolved function item from pendingCalls before
626
+ // recomputing toolInFlight — otherwise a completed call keeps
627
+ // pendingCalls.size > 0 and the latch never clears, so a later
628
+ // text-only stall stays wrongly gated as tool-bearing.
629
+ if (itemId) state.pendingCalls.delete(itemId);
630
+ state.toolTracker?.clear(item, itemId);
631
+ state.toolInFlight = state.pendingCalls.size > 0 || (state.toolTracker ? state.toolTracker.items.size > 0 : false);
619
632
  } else if (item.type === 'tool_search_call') {
620
633
  pushToolSearchCall(item);
634
+ state.toolTracker?.clear(item, item.id || '');
635
+ state.toolInFlight = state.pendingCalls.size > 0 || (state.toolTracker ? state.toolTracker.items.size > 0 : false);
621
636
  } else if (item.type === 'custom_tool_call') {
622
637
  pushCustomToolCall(item);
638
+ state.toolTracker?.clear(item, item.id || '');
639
+ state.toolInFlight = state.pendingCalls.size > 0 || (state.toolTracker ? state.toolTracker.items.size > 0 : false);
623
640
  }
624
641
  try { onStreamDelta?.(); } catch {}
625
642
  break;
@@ -746,6 +763,14 @@ export async function consumeCompatResponsesStream(stream, {
746
763
  pendingCalls: new Map(),
747
764
  emittedToolCallKeys: new Set(),
748
765
  emittedToolCall: false,
766
+ // Active tool-item / alias tracking shared with the WS + HTTP-SSE
767
+ // Responses streams (tool-stream-state.mjs): mark on output_item.added /
768
+ // arg-input deltas, clear on output_item.done. Unions id/call_id/item_id
769
+ // aliases so a mark under one key and a clear under another resolve to
770
+ // the same item — closes the custom-tool-input in-flight gap that a bare
771
+ // boolean toolInFlight latch could not (a mid-input stall now gates out
772
+ // text-only partial-final).
773
+ toolTracker: createActiveToolItemTracker(),
749
774
  completed: false,
750
775
  completedResponse: null,
751
776
  sawOutput: false,
@@ -823,6 +848,7 @@ export async function consumeCompatResponsesStream(stream, {
823
848
  || leakedCalls.length > 0
824
849
  || (state.pendingCalls && state.pendingCalls.size > 0)
825
850
  || (Array.isArray(state.toolCalls) && state.toolCalls.length > 0)
851
+ || (state.toolTracker && state.toolTracker.items.size > 0)
826
852
  || state.toolInFlight === true;
827
853
  err.partialModel = state.model || undefined;
828
854
  } catch { /* best-effort */ }
@@ -849,6 +875,7 @@ export async function consumeCompatResponsesStream(stream, {
849
875
  || leakedCalls.length > 0
850
876
  || (state.pendingCalls && state.pendingCalls.size > 0)
851
877
  || (Array.isArray(state.toolCalls) && state.toolCalls.length > 0)
878
+ || (state.toolTracker && state.toolTracker.items.size > 0)
852
879
  || state.toolInFlight === true;
853
880
  err.partialModel = state.model || undefined;
854
881
  } catch { /* best-effort */ }
@@ -105,11 +105,26 @@ export function toOpenAITools(tools) {
105
105
  }));
106
106
  }
107
107
 
108
- export function toResponsesTools(tools) {
108
+ function functionToolFromSessionTool(t, name = t?.name) {
109
+ return {
110
+ type: 'function',
111
+ name,
112
+ description: t.description,
113
+ parameters: t.inputSchema || { type: 'object', additionalProperties: true },
114
+ };
115
+ }
116
+
117
+ export function toResponsesTools(tools, options = {}) {
118
+ const provider = String(options?.provider || '').toLowerCase();
119
+ const allowNativeToolSearch = options?.nativeToolSearch === true
120
+ || (options?.nativeToolSearch !== false && provider !== 'xai');
109
121
  return tools.map((t) => {
110
122
  // load_tool advertises as the OpenAI-native `tool_search` wire type
111
- // (legacy 'tool_search' name still accepted for back-compat).
123
+ // (legacy 'tool_search' name still accepted for back-compat). xAI/Grok
124
+ // Responses rejects that OpenAI-only variant ("unknown variant
125
+ // 'tool_search'"), so serialize it as an ordinary function there.
112
126
  if (t?.name === 'load_tool' || t?.name === 'tool_search') {
127
+ if (!allowNativeToolSearch) return functionToolFromSessionTool(t, 'load_tool');
113
128
  return {
114
129
  type: 'tool_search',
115
130
  execution: 'client',
@@ -122,12 +137,7 @@ export function toResponsesTools(tools) {
122
137
  // tools (e.g. apply_patch) as ordinary function tools instead. Grammar
123
138
  // tools may carry no usable inputSchema, so fall back to a permissive
124
139
  // object schema so grok still registers a valid function tool.
125
- return {
126
- type: 'function',
127
- name: t.name,
128
- description: t.description,
129
- parameters: t.inputSchema || { type: 'object', additionalProperties: true },
130
- };
140
+ return functionToolFromSessionTool(t);
131
141
  });
132
142
  }
133
143
 
@@ -271,18 +281,10 @@ export function collectCompatResponseSearchSources(response) {
271
281
 
272
282
  export function toResponsesInputMessage(m, pendingToolMedia = null, customToolCallNameById = null) {
273
283
  if (m.role === 'tool') {
274
- if (Array.isArray(m.nativeToolSearch?.openaiTools)) {
275
- return {
276
- type: 'tool_search_output',
277
- call_id: m.toolCallId || '',
278
- status: 'completed',
279
- execution: 'client',
280
- tools: m.nativeToolSearch.openaiTools,
281
- };
282
- }
283
284
  const { output, mediaContent } = splitToolContentForOpenAIResponses(m.content);
284
285
  // xai path: never emit `custom_tool_call_output` (the `custom` variant
285
- // is rejected by grok). Replay prior tool outputs as the standard
286
+ // is rejected by grok). Replay prior tool outputs including old
287
+ // native tool_search outputs after /model switches — as the standard
286
288
  // `function_call_output` item regardless of original native type.
287
289
  const item = {
288
290
  type: 'function_call_output',
@@ -296,25 +298,16 @@ export function toResponsesInputMessage(m, pendingToolMedia = null, customToolCa
296
298
  const items = [];
297
299
  if (m.content) items.push({ role: 'assistant', content: normalizeContentForOpenAIResponses(m.content, { role: 'assistant' }) });
298
300
  for (const tc of m.toolCalls) {
299
- if (tc.nativeType === 'tool_search_call' || tc.name === 'load_tool' || tc.name === 'tool_search') {
300
- items.push({
301
- type: 'tool_search_call',
302
- call_id: tc.id,
303
- execution: 'client',
304
- arguments: tc.arguments || {},
305
- });
306
- } else {
307
- // xai path: prior native `custom_tool_call` history is replayed
308
- // as a standard `function_call` (grok rejects the `custom`
309
- // variant). tc.arguments already holds the recovered object
310
- // form, so the same stringify path as regular calls applies.
311
- items.push({
312
- type: 'function_call',
313
- call_id: tc.id,
314
- name: tc.name,
315
- arguments: JSON.stringify(tc.arguments || {}),
316
- });
317
- }
301
+ // xAI/Grok rejects OpenAI-only Responses variants such as
302
+ // `custom_tool_call` and `tool_search_call`, including when they
303
+ // appear in replayed history after a model switch. Replay all prior
304
+ // client tools as plain function calls.
305
+ items.push({
306
+ type: 'function_call',
307
+ call_id: tc.id,
308
+ name: tc.name === 'tool_search' ? 'load_tool' : tc.name,
309
+ arguments: JSON.stringify(tc.arguments || {}),
310
+ });
318
311
  }
319
312
  return items;
320
313
  }
@@ -351,6 +344,7 @@ export function toXaiResponsesInput(messages, providerState, options = {}) {
351
344
  startIndex = Math.max(0, Math.min(seen, messages.length));
352
345
  if (messages[startIndex]?.role === 'assistant') startIndex += 1;
353
346
  }
347
+ const dropToolHistory = !previousResponseId && (resetReason !== null || !state?.previousResponseId);
354
348
  const input = [];
355
349
  const pendingToolMedia = [];
356
350
  const customToolCallNameById = new Map();
@@ -361,6 +355,11 @@ export function toXaiResponsesInput(messages, providerState, options = {}) {
361
355
  for (const m of messages.slice(startIndex)) {
362
356
  if (!includeSystem && m.role === 'system') continue;
363
357
  if (m.role !== 'tool') flushToolMedia();
358
+ if (dropToolHistory && m.role === 'tool') continue;
359
+ if (dropToolHistory && m.role === 'assistant' && Array.isArray(m.toolCalls) && m.toolCalls.length > 0) {
360
+ if (m.content) input.push({ role: 'assistant', content: normalizeContentForOpenAIResponses(m.content, { role: 'assistant' }) });
361
+ continue;
362
+ }
364
363
  const converted = toResponsesInputMessage(m, pendingToolMedia, customToolCallNameById);
365
364
  if (Array.isArray(converted)) input.push(...converted);
366
365
  else input.push(converted);
@@ -467,7 +467,7 @@ export class OpenAICompatProvider {
467
467
  };
468
468
  if (previousResponseId) params.previous_response_id = previousResponseId;
469
469
  const nativeTools = nativeResponsesTools(opts);
470
- if (tools?.length || nativeTools.length) params.tools = [...nativeTools, ...toResponsesTools(tools || [])];
470
+ if (tools?.length || nativeTools.length) params.tools = [...nativeTools, ...toResponsesTools(tools || [], { provider: 'xai' })];
471
471
  // SSE transport: report 'requesting' until the stream opens, then
472
472
  // per-chunk onStreamDelta feeds the agent stall watchdog.
473
473
  try { opts.onStageChange?.('requesting'); } catch { /* heartbeat best-effort */ }
@@ -664,7 +664,7 @@ export class OpenAICompatProvider {
664
664
  // first response already anchors instructions for the continuation.
665
665
  else if (instructions) params.instructions = instructions;
666
666
  const nativeTools = nativeResponsesTools(opts);
667
- if (tools?.length || nativeTools.length) params.tools = [...nativeTools, ...toResponsesTools(tools || [])];
667
+ if (tools?.length || nativeTools.length) params.tools = [...nativeTools, ...toResponsesTools(tools || [], { provider: 'xai' })];
668
668
  const reasoningEffort = normalizeXaiReasoningEffort(opts.xaiReasoningEffort
669
669
  ?? opts.effort
670
670
  ?? this.config?.reasoningEffort