mixdog 0.9.19 → 0.9.21

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 (120) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +100 -34
  3. package/package.json +3 -2
  4. package/scripts/build-tui.mjs +6 -0
  5. package/scripts/hook-bus-test.mjs +8 -0
  6. package/scripts/log-writer-guard-smoke.mjs +131 -0
  7. package/scripts/reactive-compact-persist-smoke.mjs +22 -6
  8. package/scripts/recall-bench-cases.json +0 -1
  9. package/scripts/recall-quality-cases.json +1 -2
  10. package/scripts/session-ingest-compaction-smoke.mjs +241 -0
  11. package/scripts/tool-smoke.mjs +150 -45
  12. package/src/defaults/skills/setup/SKILL.md +327 -0
  13. package/src/help.mjs +2 -5
  14. package/src/lib/mixdog-debug.cjs +13 -0
  15. package/src/mixdog-session-runtime.mjs +7 -3328
  16. package/src/runtime/agent/orchestrator/context/collect.mjs +33 -9
  17. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +34 -2
  18. package/src/runtime/agent/orchestrator/providers/gemini.mjs +3 -0
  19. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +67 -10
  20. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +3 -0
  21. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +40 -3
  22. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +73 -3
  23. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +663 -0
  24. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +6 -0
  25. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +153 -0
  26. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +49 -0
  27. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +9 -1
  28. package/src/runtime/agent/orchestrator/session/loop.mjs +8 -1860
  29. package/src/runtime/agent/orchestrator/session/manager/agent-runtime-singleton.mjs +29 -0
  30. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +686 -0
  31. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +60 -12
  32. package/src/runtime/agent/orchestrator/session/manager/env-utils.mjs +8 -0
  33. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +159 -0
  34. package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +143 -0
  35. package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +268 -0
  36. package/src/runtime/agent/orchestrator/session/manager/provider-cache-key.mjs +22 -0
  37. package/src/runtime/agent/orchestrator/session/manager/runtime-loaders.mjs +26 -0
  38. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +124 -0
  39. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +258 -0
  40. package/src/runtime/agent/orchestrator/session/manager/session-errors.mjs +20 -0
  41. package/src/runtime/agent/orchestrator/session/manager/session-id.mjs +9 -0
  42. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +475 -0
  43. package/src/runtime/agent/orchestrator/session/manager/session-lock.mjs +23 -0
  44. package/src/runtime/agent/orchestrator/session/manager.mjs +88 -2285
  45. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +440 -0
  46. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +153 -0
  47. package/src/runtime/agent/orchestrator/session/store.mjs +4 -1
  48. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +642 -0
  49. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +23 -3
  50. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +108 -2
  51. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +15 -3
  52. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +7 -0
  53. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +24 -2
  54. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +2 -2
  55. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +72 -8
  56. package/src/runtime/agent/orchestrator/tools/shell-policy-danger-target.mjs +5 -1
  57. package/src/runtime/channels/index.mjs +6 -2183
  58. package/src/runtime/channels/lib/inbound-handler.mjs +328 -0
  59. package/src/runtime/channels/lib/interaction-handlers.mjs +260 -0
  60. package/src/runtime/channels/lib/network-retry.mjs +23 -0
  61. package/src/runtime/channels/lib/owned-runtime.mjs +627 -0
  62. package/src/runtime/channels/lib/runtime-paths.mjs +20 -9
  63. package/src/runtime/channels/lib/transcript-binding.mjs +336 -0
  64. package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +39 -2
  65. package/src/runtime/channels/lib/worker-bootstrap.mjs +97 -0
  66. package/src/runtime/channels/lib/worker-ipc.mjs +201 -0
  67. package/src/runtime/channels/lib/worker-main.mjs +777 -0
  68. package/src/runtime/memory/index.mjs +163 -1725
  69. package/src/runtime/memory/lib/embedding-provider.mjs +4 -2
  70. package/src/runtime/memory/lib/embedding-worker.mjs +47 -6
  71. package/src/runtime/memory/lib/http-router.mjs +811 -0
  72. package/src/runtime/memory/lib/memory-action-handlers.mjs +901 -0
  73. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +6 -0
  74. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +46 -9
  75. package/src/runtime/memory/lib/memory-cycle2.mjs +87 -11
  76. package/src/runtime/memory/lib/memory-embed.mjs +28 -7
  77. package/src/runtime/memory/lib/memory-recall-store.mjs +4 -4
  78. package/src/runtime/memory/lib/memory.mjs +39 -0
  79. package/src/runtime/memory/lib/query-handlers.mjs +2 -2
  80. package/src/runtime/memory/lib/session-ingest-runtime.mjs +401 -0
  81. package/src/runtime/shared/atomic-file.mjs +138 -80
  82. package/src/runtime/shared/child-guardian.mjs +61 -3
  83. package/src/session-runtime/boot-profile.mjs +36 -0
  84. package/src/session-runtime/channel-config-api.mjs +70 -0
  85. package/src/session-runtime/context-status.mjs +181 -0
  86. package/src/session-runtime/env.mjs +17 -0
  87. package/src/session-runtime/lifecycle-api.mjs +242 -0
  88. package/src/session-runtime/model-route-api.mjs +198 -0
  89. package/src/session-runtime/provider-auth-api.mjs +135 -0
  90. package/src/session-runtime/resource-api.mjs +282 -0
  91. package/src/session-runtime/runtime-core.mjs +2104 -0
  92. package/src/session-runtime/session-turn-api.mjs +274 -0
  93. package/src/session-runtime/tool-catalog.mjs +18 -264
  94. package/src/session-runtime/tool-defs.mjs +2 -2
  95. package/src/session-runtime/workflow-agents-api.mjs +238 -0
  96. package/src/standalone/agent-tool.mjs +2 -2
  97. package/src/standalone/channel-worker.mjs +67 -7
  98. package/src/standalone/folder-dialog.mjs +4 -1
  99. package/src/standalone/memory-runtime-proxy.mjs +154 -17
  100. package/src/standalone/seeds.mjs +28 -1
  101. package/src/tui/App.jsx +40 -28
  102. package/src/tui/app/core-memory-picker.mjs +1 -1
  103. package/src/tui/app/doctor.mjs +175 -0
  104. package/src/tui/app/slash-commands.mjs +1 -0
  105. package/src/tui/app/slash-dispatch.mjs +9 -0
  106. package/src/tui/app/use-mouse-input.mjs +6 -0
  107. package/src/tui/app/use-transcript-scroll.mjs +77 -3
  108. package/src/tui/dist/index.mjs +2851 -2162
  109. package/src/tui/engine/context-state.mjs +145 -0
  110. package/src/tui/engine/prompt-history.mjs +27 -0
  111. package/src/tui/engine/session-api-ext.mjs +478 -0
  112. package/src/tui/engine/session-api.mjs +564 -0
  113. package/src/tui/engine/session-flow.mjs +485 -0
  114. package/src/tui/engine/turn.mjs +1078 -0
  115. package/src/tui/engine.mjs +68 -2620
  116. package/src/tui/index.jsx +7 -0
  117. package/vendor/ink/build/ink.js +16 -1
  118. package/vendor/ink/build/output.js +30 -4
  119. package/vendor/ink/build/render.js +5 -0
  120. package/src/workflows/sequential/WORKFLOW.md +0 -51
@@ -319,16 +319,34 @@ export function bp1HasDeferredToolManifestBlock(text) {
319
319
  }
320
320
 
321
321
  /**
322
- * Names-only manifest for tools in the deferred pool (catalog minus active wire tools).
322
+ * Skill-style manifest for tools in the deferred pool (catalog minus active
323
+ * wire tools). Each entry is either a bare name string or `{ name, description }`;
324
+ * output lines are `- name: description` (description omitted when absent),
325
+ * mirroring the available-skills manifest so the model calls deferred tools
326
+ * directly. Descriptions are compacted and stripped of `<`/`>`.
323
327
  * Empty pool → '' (caller omits the block).
324
328
  */
325
- export function buildDeferredToolManifest(names) {
326
- const list = [...new Set((Array.isArray(names) ? names : [])
327
- .map((name) => sanitizeDeferredToolManifestName(name))
328
- .filter(Boolean))]
329
- .sort((a, b) => a.localeCompare(b));
329
+ export function buildDeferredToolManifest(entries) {
330
+ const list = [];
331
+ const seen = new Set();
332
+ for (const entry of Array.isArray(entries) ? entries : []) {
333
+ const rawName = typeof entry === 'string' ? entry : entry?.name;
334
+ const name = sanitizeDeferredToolManifestName(rawName);
335
+ if (!name || seen.has(name)) continue;
336
+ seen.add(name);
337
+ const description = typeof entry === 'string'
338
+ ? ''
339
+ : compactSkillManifestText(String(entry?.description || '').replace(/[<>]/g, ''));
340
+ list.push({ name, description });
341
+ }
330
342
  if (!list.length) return '';
331
- return ['<available-deferred-tools>', ...list, '</available-deferred-tools>'].join('\n');
343
+ list.sort((a, b) => a.name.localeCompare(b.name));
344
+ return [
345
+ '<available-deferred-tools>',
346
+ 'You may call any tool listed below directly by name with its arguments; it auto-loads on first call.',
347
+ ...list.map((entry) => (entry.description ? `- ${entry.name}: ${entry.description}` : `- ${entry.name}`)),
348
+ '</available-deferred-tools>',
349
+ ].join('\n');
332
350
  }
333
351
 
334
352
  function sanitizeMcpManifestServerName(name) {
@@ -389,12 +407,18 @@ export function stripDeferredToolManifestBlock(text) {
389
407
  .trimEnd();
390
408
  }
391
409
 
392
- /** Inject names-only deferred pool into BP1 once at session start; never rewrite BP1 after. */
410
+ /** Inject the skill-style deferred pool (name + description) into BP1 once at session start; never rewrite BP1 after. */
393
411
  export function applyInitialDeferredToolManifestToBp1(session, poolNames) {
394
412
  if (!session || !Array.isArray(session.messages) || session.deferredToolBp1Applied) return false;
395
413
  const pool = Array.isArray(poolNames) ? poolNames : [];
414
+ const descByName = new Map();
415
+ for (const tool of Array.isArray(session?.deferredToolCatalog) ? session.deferredToolCatalog : []) {
416
+ const name = String(tool?.name || '').trim();
417
+ if (name && !descByName.has(name)) descByName.set(name, String(tool?.description || ''));
418
+ }
419
+ const entries = pool.map((name) => ({ name, description: descByName.get(String(name).trim()) || '' }));
396
420
  const parts = [];
397
- const deferredManifest = buildDeferredToolManifest(pool);
421
+ const deferredManifest = buildDeferredToolManifest(entries);
398
422
  if (deferredManifest) parts.push(deferredManifest);
399
423
  const mcpManifest = buildMcpInstructionsManifest(session.mcpServerInstructions, pool);
400
424
  if (mcpManifest) parts.push(mcpManifest);
@@ -1,7 +1,7 @@
1
1
  import { createRequire } from 'node:module';
2
2
  import { loadConfig } from '../config.mjs';
3
3
  import { sanitizeToolPairs, sanitizeAnthropicContentPairs, foldUserTextIntoToolResultTail } from '../session/context-utils.mjs';
4
- import { classifyError, midstreamBackoffFor, sleepWithAbort, withRetry } from './retry-classifier.mjs';
4
+ import { classifyError, midstreamBackoffFor, sleepWithAbort, withRetry, retryAfterMsFromError } from './retry-classifier.mjs';
5
5
  import { traceAgentUsage } from '../agent-trace.mjs';
6
6
  import {
7
7
  PROVIDER_FIRST_BYTE_TIMEOUT_MS,
@@ -518,7 +518,8 @@ export class AnthropicProvider {
518
518
  try {
519
519
  return await this._doSend(messages, model, tools, sendOpts);
520
520
  } catch (err) {
521
- if (err.message && (err.message.includes('401') || err.message.includes('403'))) {
521
+ if (err.message && (err.message.includes('401') || err.message.includes('403'))
522
+ && !err.liveTextEmitted && !err.emittedToolCall && !err.unsafeToRetry) {
522
523
  process.stderr.write(`[provider] Auth error, re-reading config...\n`);
523
524
  this.reloadApiKey();
524
525
  return await this._doSend(messages, model, tools, sendOpts);
@@ -745,6 +746,22 @@ export class AnthropicProvider {
745
746
  const err = new Error(`Anthropic API ${res.status}: ${text.slice(0, 200)}`);
746
747
  err.status = res.status;
747
748
  err.httpStatus = res.status;
749
+ // Carry response headers so withRetry can honor a
750
+ // short Retry-After and upstream can read quota hints.
751
+ err.headers = res.headers;
752
+ err.response = { status: res.status, headers: res.headers };
753
+ // 429: promote to a quota-style error equivalent to
754
+ // anthropic-oauth's anthropicQuotaError so upstream
755
+ // quota handling and unsafe-to-retry gating apply.
756
+ if (res.status === 429) {
757
+ const retryAfterMs = retryAfterMsFromError({ headers: res.headers, response: { headers: res.headers } });
758
+ err.name = 'ProviderQuotaError';
759
+ err.code = 'PROVIDER_QUOTA';
760
+ err.retryAfterMs = retryAfterMs;
761
+ err.providerQuota = true;
762
+ err.quotaExceeded = true;
763
+ err.unsafeToRetry = true;
764
+ }
748
765
  throw err;
749
766
  }
750
767
  if (!res.body) {
@@ -858,6 +875,21 @@ export class AnthropicProvider {
858
875
  await _midstreamSleepWithAbort(midstreamBackoffFor(attemptIndex + 1), totalSignal);
859
876
  continue;
860
877
  }
878
+ if (classifyError(err) === 'transient'
879
+ && !midState.sawMessageStart
880
+ && !midState.emittedToolCall
881
+ && attemptIndex < MAX_MIDSTREAM_RETRIES) {
882
+ firstAttemptError = err;
883
+ firstAttemptClassifier = err?.providerErrorType || 'sse_transient';
884
+ try { streamController.abort?.(err); } catch {}
885
+ try {
886
+ process.stderr.write(
887
+ `[${this.name}] transient SSE error — retry ${attemptIndex + 1}/${MAX_MIDSTREAM_RETRIES} (${err?.providerErrorType || err?.message || 'unknown'})\n`,
888
+ );
889
+ } catch {}
890
+ await _midstreamSleepWithAbort(midstreamBackoffFor(attemptIndex + 1), totalSignal);
891
+ continue;
892
+ }
861
893
  if ((err?.truncatedStream === true || err?.code === 'TRUNCATED_STREAM')
862
894
  && classifyError(err) === 'transient'
863
895
  && !midState.emittedToolCall
@@ -478,6 +478,9 @@ export class GeminiProvider {
478
478
  return await this._doSend(messages, model, tools, sendOpts);
479
479
  } catch (err) {
480
480
  if (err.message && (err.message.includes('401') || err.message.includes('403'))) {
481
+ if (err.liveTextEmitted === true || err.emittedToolCall === true || err.unsafeToRetry === true) {
482
+ throw err;
483
+ }
481
484
  process.stderr.write(`[provider] Auth error, re-reading config...\n`);
482
485
  this.reloadApiKey();
483
486
  return await this._doSend(messages, model, tools, sendOpts);
@@ -308,10 +308,7 @@ export function forgetGrokOAuthCredentials() {
308
308
  }
309
309
 
310
310
  let _refreshInFlight = null;
311
- async function refreshTokens(tokens) {
312
- if (!tokens?.refresh_token) {
313
- throw new Error('[grok-oauth] refresh token not available — open /providers in mixdog to sign in again');
314
- }
311
+ async function _postRefresh(tokens) {
315
312
  const tokenEndpoint = tokens.token_endpoint
316
313
  ? assertTrustedXaiEndpoint(tokens.token_endpoint, 'token endpoint')
317
314
  : (await fetchDiscovery()).token_endpoint;
@@ -365,6 +362,34 @@ async function refreshTokens(tokens) {
365
362
  }
366
363
  }
367
364
 
365
+ // 16 mixdog processes share one grok-oauth.json and xAI rotates refresh
366
+ // tokens single-use. Re-read the store immediately before the network POST so
367
+ // a peer's just-completed rotation is adopted instead of spending our
368
+ // (now-stale) refresh_token; on invalid_grant, re-read once and retry with a
369
+ // peer-rotated refresh_token before propagating.
370
+ async function refreshTokens(tokens, { force = false } = {}) {
371
+ if (!tokens?.refresh_token) {
372
+ throw new Error('[grok-oauth] refresh token not available — open /providers in mixdog to sign in again');
373
+ }
374
+ const disk = _loadOwnTokens();
375
+ const validAfter = Date.now() + (force ? 0 : TOKEN_REFRESH_SKEW_MS);
376
+ if (disk?.access_token && disk.access_token !== tokens.access_token
377
+ && (!disk.expires_at || disk.expires_at >= validAfter)) {
378
+ return disk;
379
+ }
380
+ try {
381
+ return await _postRefresh(tokens);
382
+ } catch (err) {
383
+ if (err?.isInvalidGrant) {
384
+ const rotated = _loadOwnTokens();
385
+ if (rotated?.refresh_token && rotated.refresh_token !== tokens.refresh_token) {
386
+ return await _postRefresh(rotated);
387
+ }
388
+ }
389
+ throw err;
390
+ }
391
+ }
392
+
368
393
  // --- Model catalog cache (24h disk TTL) ---
369
394
  const _modelCache = makeModelCache({
370
395
  fileName: 'grok-oauth-models.json',
@@ -530,6 +555,9 @@ export class GrokOAuthProvider {
530
555
  tokens = null;
531
556
  _inner = null;
532
557
  _innerKey = null;
558
+ // Grace window after a non-force refresh failure: keep serving the current
559
+ // still-valid access_token instead of thrashing the shared refresh_token.
560
+ _refreshFallbackUntil = 0;
533
561
 
534
562
  constructor(config) {
535
563
  this.config = config || {};
@@ -553,15 +581,44 @@ export class GrokOAuthProvider {
553
581
  if (disk?.access_token) this.tokens = disk;
554
582
  this._lastDiskScan = ownM;
555
583
  }
584
+ if (!forceRefresh && this._refreshFallbackUntil > Date.now() && this.tokens?.access_token
585
+ && (!this.tokens.expires_at || this.tokens.expires_at > Date.now())) {
586
+ return this.tokens;
587
+ }
556
588
  const expiring = this.tokens.expires_at
557
589
  && this.tokens.expires_at < Date.now() + TOKEN_REFRESH_SKEW_MS;
558
590
  if (forceRefresh || expiring) {
559
- if (_refreshInFlight) {
560
- this.tokens = await _refreshInFlight;
561
- } else {
562
- _refreshInFlight = refreshTokens(this.tokens)
563
- .finally(() => { _refreshInFlight = null; });
564
- this.tokens = await _refreshInFlight;
591
+ const currentToken = this.tokens?.access_token || null;
592
+ try {
593
+ if (_refreshInFlight) {
594
+ const shared = await _refreshInFlight;
595
+ this.tokens = shared;
596
+ // A forced caller must not accept a shared non-force result
597
+ // that merely handed back its own prior token: start a fresh
598
+ // forced refresh instead (mirror openai-oauth).
599
+ if (forceRefresh && shared?.access_token === currentToken) {
600
+ if (!_refreshInFlight) {
601
+ _refreshInFlight = refreshTokens(this.tokens, { force: true })
602
+ .finally(() => { _refreshInFlight = null; });
603
+ }
604
+ this.tokens = await _refreshInFlight;
605
+ }
606
+ } else {
607
+ _refreshInFlight = refreshTokens(this.tokens, { force: forceRefresh })
608
+ .finally(() => { _refreshInFlight = null; });
609
+ this.tokens = await _refreshInFlight;
610
+ }
611
+ this._refreshFallbackUntil = 0;
612
+ } catch (err) {
613
+ // Non-force failure while the current token is still valid: serve
614
+ // it under a grace window rather than throwing (mirror openai-oauth).
615
+ if (!forceRefresh && currentToken
616
+ && (!this.tokens?.expires_at || this.tokens.expires_at > Date.now())) {
617
+ this._refreshFallbackUntil = Date.now() + TOKEN_REFRESH_SKEW_MS;
618
+ process.stderr.write(`[grok-oauth] Refresh failed (${String(err?.message || err).slice(0, 120)}); using still-valid current token\n`);
619
+ return this.tokens;
620
+ }
621
+ throw err;
565
622
  }
566
623
  }
567
624
  return this.tokens;
@@ -173,6 +173,9 @@ export class OpenAICompatProvider {
173
173
  return await this._doSend(messages, model, tools, sendOpts);
174
174
  } catch (err) {
175
175
  if (err.message && (err.message.includes('401') || err.message.includes('403'))) {
176
+ if (err.liveTextEmitted === true || err.emittedToolCall === true || err.unsafeToRetry === true) {
177
+ throw err;
178
+ }
176
179
  process.stderr.write(`[provider] Auth error, re-reading config...\n`);
177
180
  this.reloadApiKey();
178
181
  return await this._doSend(messages, model, tools, sendOpts);
@@ -27,6 +27,13 @@ import { createLeakGuard, createToolCallDedupe, dedupeToolCallList } from './ant
27
27
  import { customToolCallFromResponseItem } from './custom-tool-wire.mjs';
28
28
  import { CODEX_OAUTH_ORIGINATOR, CODEX_RESPONSES_URL, _displayCodexModel } from './openai-oauth.mjs';
29
29
 
30
+ // Public OpenAI Responses API endpoint for the api-key `openai` provider.
31
+ // The openai-direct WS transport hits the same origin (openai-ws-pool
32
+ // OPENAI_WS_URL = wss://api.openai.com/v1/responses); this HTTP/SSE fallback
33
+ // mirrors it so OpenAIDirectProvider can fall back off WebSocket like
34
+ // openai-oauth. Same Responses SSE wire format, only endpoint + auth differ.
35
+ const OPENAI_DIRECT_RESPONSES_URL = 'https://api.openai.com/v1/responses';
36
+
30
37
  export function _envFlag(name, fallback = true) {
31
38
  const raw = process.env[name];
32
39
  if (raw == null || raw === '') return fallback;
@@ -116,6 +123,18 @@ function _pushOutputTextAnnotations(part, citations, citationKeys) {
116
123
  }
117
124
 
118
125
  function _buildOpenAIHttpFallbackHeaders({ auth, cacheKey }) {
126
+ if (auth?.type === 'openai-direct') {
127
+ // Public API-key auth: Bearer <OPENAI_API_KEY>, no chatgpt-account-id /
128
+ // originator (mirrors openai-ws-pool _buildHandshakeHeaders' direct
129
+ // branch). session_id anchors are an OAuth-backend behavior, so omit
130
+ // them — the public API keys its prefix cache off body.prompt_cache_key.
131
+ return {
132
+ Authorization: `Bearer ${auth.apiKey}`,
133
+ 'Content-Type': 'application/json',
134
+ Accept: 'text/event-stream',
135
+ 'x-client-request-id': randomBytes(16).toString('hex'),
136
+ };
137
+ }
119
138
  const headers = {
120
139
  Authorization: `Bearer ${auth.access_token}`,
121
140
  'Content-Type': 'application/json',
@@ -189,10 +208,13 @@ export async function sendViaHttpSse({
189
208
  );
190
209
  const headers = _buildOpenAIHttpFallbackHeaders({ auth, cacheKey });
191
210
  const fetchStartedAt = Date.now();
211
+ const responsesUrl = auth?.type === 'openai-direct'
212
+ ? OPENAI_DIRECT_RESPONSES_URL
213
+ : CODEX_RESPONSES_URL;
192
214
  let response;
193
215
  try {
194
216
  try { onStageChange?.('requesting'); } catch {}
195
- response = await fetchFn(CODEX_RESPONSES_URL, {
217
+ response = await fetchFn(responsesUrl, {
196
218
  method: 'POST',
197
219
  headers,
198
220
  body: JSON.stringify(body),
@@ -304,6 +326,17 @@ export async function sendViaHttpSse({
304
326
  // a second attempt.
305
327
  let emittedText = false;
306
328
 
329
+ // Tool-emit invariant (mirrors emittedText, WS path's emittedToolCall): set
330
+ // once onToolCall has actually dispatched a call. A failure afterwards is
331
+ // non-retryable — the side-effecting tool already ran, and any upstream
332
+ // retry/fallback would double-execute it. Stamped onto errors below so
333
+ // shouldFallbackTransport / the WS auth-retry gate refuse to reissue.
334
+ let emittedToolCall = false;
335
+ const _stampToolSafety = (err) => {
336
+ if (emittedToolCall && err) { try { err.emittedToolCall = true; err.unsafeToRetry = true; } catch {} }
337
+ return err;
338
+ };
339
+
307
340
  // Single-emit guard for tool calls (matches the WS path's
308
341
  // emittedToolCall intent). The HTTP/SSE event stream can surface the
309
342
  // same function_call across multiple frames — response.function_call_arguments.done,
@@ -324,6 +357,7 @@ export async function sendViaHttpSse({
324
357
  if (emittedToolCallIds.has(call.id)) return;
325
358
  emittedToolCallIds.add(call.id);
326
359
  if (!_toolDedupe.shouldDispatch(call.name, call.arguments)) return;
360
+ emittedToolCall = true;
327
361
  try { onToolCall?.(call); } catch {}
328
362
  };
329
363
 
@@ -676,6 +710,9 @@ export async function sendViaHttpSse({
676
710
  // Live-text invariant: once a non-empty chunk has been relayed it
677
711
  // cannot be withdrawn — flag the error so no upstream layer retries.
678
712
  if (emittedText && err) { try { err.liveTextEmitted = true; err.unsafeToRetry = true; } catch {} }
713
+ // Tool-emit invariant: an error after a dispatched tool call must not
714
+ // reissue the turn (double-execution). Stamp emittedToolCall too.
715
+ _stampToolSafety(err);
679
716
  throw err;
680
717
  } finally {
681
718
  _clearSemanticIdle();
@@ -688,10 +725,10 @@ export async function sendViaHttpSse({
688
725
 
689
726
  const unresolved = toolCalls.find(t => t._pendingItemId);
690
727
  if (unresolved) {
691
- throw new Error(`OpenAI OAuth HTTP fallback function_call salvage failed: missing call_id/name for item_id=${unresolved._pendingItemId || '?'}`);
728
+ throw _stampToolSafety(new Error(`OpenAI OAuth HTTP fallback function_call salvage failed: missing call_id/name for item_id=${unresolved._pendingItemId || '?'}`));
692
729
  }
693
730
  if (!completed && !content && !toolCalls.length) {
694
- throw new Error('OpenAI OAuth HTTP fallback ended before response.completed');
731
+ throw _stampToolSafety(new Error('OpenAI OAuth HTTP fallback ended before response.completed'));
695
732
  }
696
733
 
697
734
  const liveModel = model || useModel;
@@ -16,6 +16,9 @@ import { sendViaWebSocket } from './openai-oauth-ws.mjs';
16
16
  import { buildRequestBody } from './openai-oauth.mjs';
17
17
  import { enrichModels } from './model-catalog.mjs';
18
18
  import { sanitizeModelList } from './model-list-sanitize.mjs';
19
+ import { sendViaHttpSse, _envFlag } from './openai-oauth-http-sse.mjs';
20
+ import { shouldFallbackTransport } from './retry-classifier.mjs';
21
+ import { loadConfig } from '../config.mjs';
19
22
  import {
20
23
  resolveProviderCacheKey,
21
24
  resolveProviderPromptCacheLane,
@@ -52,6 +55,23 @@ export class OpenAIDirectProvider {
52
55
  if (!k) throw new Error('OPENAI_API_KEY not configured (providers.openai.apiKey)');
53
56
  return k;
54
57
  }
58
+ // Auth-recovery mirror of openai-compat.reloadApiKey: on a 401/403 the key
59
+ // was likely rotated in config after this provider instance was built, so
60
+ // re-read providers.openai.apiKey from disk before the single retry.
61
+ // Returns the fresh key (or null if none) — no client to rebuild here since
62
+ // the WS/HTTP transports take the key per-call via the `auth` object.
63
+ reloadApiKey() {
64
+ try {
65
+ const freshConfig = loadConfig();
66
+ const cfg = freshConfig.providers?.openai;
67
+ const newKey = cfg?.apiKey || this.config.apiKey;
68
+ if (newKey) {
69
+ this.config = { ...(this.config || {}), ...(cfg || {}), apiKey: newKey };
70
+ return newKey;
71
+ }
72
+ } catch { /* best effort */ }
73
+ return null;
74
+ }
55
75
  async send(messages, model, tools, sendOpts) {
56
76
  const opts = sendOpts || {};
57
77
  const onStageChange = typeof opts.onStageChange === 'function' ? opts.onStageChange : null;
@@ -109,10 +129,8 @@ export class OpenAIDirectProvider {
109
129
  const cacheKey = body.prompt_cache_key || resolveProviderCacheKey(opts, 'openai');
110
130
  const iteration = Number.isFinite(Number(opts.iteration)) ? Number(opts.iteration) : null;
111
131
  const auth = { type: 'openai-direct', apiKey };
112
- return sendViaWebSocket({
113
- auth,
132
+ const common = {
114
133
  body,
115
- sendOpts: opts,
116
134
  onStreamDelta,
117
135
  onToolCall,
118
136
  onTextDelta,
@@ -122,8 +140,60 @@ export class OpenAIDirectProvider {
122
140
  cacheKey,
123
141
  iteration,
124
142
  useModel,
143
+ };
144
+ const dispatchWs = (a) => sendViaWebSocket({
145
+ ...common,
146
+ auth: a,
147
+ sendOpts: opts,
125
148
  displayModel: (id) => id,
126
149
  });
150
+ // WS→HTTP/SSE fallback mirrors the openai-oauth wrapper: the shared
151
+ // HTTP transport now accepts auth.type==='openai-direct' (public
152
+ // Responses endpoint + Bearer <apiKey>), so the api-key provider gets
153
+ // the same envelope. Gate via shouldFallbackTransport (denies
154
+ // 401/403/404/429 + liveTextEmitted/emittedToolCall/unsafeToRetry).
155
+ const httpFallbackEnabled = _envFlag('MIXDOG_OPENAI_HTTP_FALLBACK', true);
156
+ const dispatchHttp = (a) => {
157
+ if (!process.env.MIXDOG_QUIET_PROVIDER_LOG) {
158
+ process.stderr.write('[openai-ws] WebSocket unhealthy; falling back to HTTP/SSE\n');
159
+ }
160
+ return sendViaHttpSse({ ...common, auth: a, opts, fetchFn: opts._fetchFn });
161
+ };
162
+ try {
163
+ return await dispatchWs(auth);
164
+ } catch (err) {
165
+ const status = err?.httpStatus;
166
+ // Live-text/tool invariant: never reissue a turn that already
167
+ // relayed visible output or dispatched a tool call.
168
+ const unsafeToRetry = err?.liveTextEmitted === true
169
+ || err?.emittedToolCall === true
170
+ || err?.unsafeToRetry === true;
171
+ // (1) 401/403 → reload apiKey from config and retry once over WS.
172
+ // shouldFallbackTransport denies 401/403, so this branch owns
173
+ // its own guard.
174
+ if ((status === 401 || status === 403) && !unsafeToRetry) {
175
+ process.stderr.write(`[openai-ws] ${status} — reloading apiKey and retrying once\n`);
176
+ const freshKey = this.reloadApiKey();
177
+ if (freshKey) {
178
+ const retryAuth = { type: 'openai-direct', apiKey: freshKey };
179
+ try {
180
+ return await dispatchWs(retryAuth);
181
+ } catch (retryErr) {
182
+ if (shouldFallbackTransport(retryErr, { signal: externalSignal, enabled: httpFallbackEnabled })) {
183
+ return await dispatchHttp(retryAuth);
184
+ }
185
+ throw retryErr;
186
+ }
187
+ }
188
+ throw err;
189
+ }
190
+ // (2) WS transport failure → HTTP/SSE fallback (predicate handles
191
+ // the safety denies).
192
+ if (shouldFallbackTransport(err, { signal: externalSignal, enabled: httpFallbackEnabled })) {
193
+ return await dispatchHttp(auth);
194
+ }
195
+ throw err;
196
+ }
127
197
  }
128
198
  async listModels() {
129
199
  try {