mixdog 0.9.0 → 0.9.1

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 (57) hide show
  1. package/package.json +3 -3
  2. package/scripts/session-ingest-smoke.mjs +2 -2
  3. package/src/headless-role.mjs +1 -1
  4. package/src/lib/mixdog-debug.cjs +0 -22
  5. package/src/lib/plugin-paths.cjs +1 -7
  6. package/src/lib/rules-builder.cjs +2 -2
  7. package/src/mixdog-session-runtime.mjs +0 -1
  8. package/src/repl.mjs +0 -2
  9. package/src/runtime/agent/orchestrator/internal-roles.mjs +1 -1
  10. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +27 -49
  11. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +5 -20
  12. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +8 -27
  13. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +52 -182
  14. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +8 -30
  15. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +258 -0
  16. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +1 -1
  17. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +0 -8
  18. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +1 -44
  19. package/src/runtime/channels/index.mjs +0 -30
  20. package/src/runtime/channels/lib/cli-worker-host.mjs +1 -8
  21. package/src/runtime/channels/lib/config.mjs +0 -1
  22. package/src/runtime/channels/lib/drop-trace.mjs +1 -1
  23. package/src/runtime/channels/lib/executor.mjs +0 -3
  24. package/src/runtime/channels/lib/memory-client.mjs +0 -38
  25. package/src/runtime/channels/lib/output-forwarder.mjs +1 -8
  26. package/src/runtime/channels/lib/runtime-paths.mjs +0 -6
  27. package/src/runtime/channels/lib/session-discovery.mjs +0 -4
  28. package/src/runtime/channels/lib/tool-format.mjs +0 -1
  29. package/src/runtime/channels/lib/transcript-discovery.mjs +1 -10
  30. package/src/runtime/lib/keychain-cjs.cjs +0 -1
  31. package/src/runtime/memory/data/runtime-manifest.json +6 -7
  32. package/src/runtime/memory/index.mjs +3 -24
  33. package/src/runtime/memory/lib/llm-worker-host.mjs +0 -4
  34. package/src/runtime/memory/lib/memory-ops-policy.mjs +0 -1
  35. package/src/runtime/memory/lib/runtime-fetcher.mjs +43 -18
  36. package/src/runtime/memory/lib/session-ingest.mjs +9 -7
  37. package/src/runtime/search/index.mjs +2 -7
  38. package/src/runtime/search/lib/config.mjs +0 -4
  39. package/src/runtime/search/lib/state.mjs +1 -15
  40. package/src/runtime/search/lib/web-tools.mjs +0 -1
  41. package/src/runtime/shared/child-spawn-gate.mjs +0 -6
  42. package/src/standalone/seeds.mjs +1 -11
  43. package/src/tui/App.jsx +35 -12
  44. package/src/tui/components/PromptInput.jsx +63 -2
  45. package/src/tui/components/ToolExecution.jsx +7 -2
  46. package/src/tui/components/tool-output-format.mjs +156 -22
  47. package/src/tui/components/tool-output-format.test.mjs +93 -1
  48. package/src/tui/dist/index.mjs +473 -116
  49. package/src/tui/markdown/format-token.mjs +267 -108
  50. package/src/tui/markdown/format-token.test.mjs +105 -9
  51. package/src/tui/theme.mjs +10 -0
  52. package/src/vendor/statusline/bin/statusline-lib.mjs +0 -623
  53. package/vendor/ink/build/ink.js +54 -8
  54. package/src/hooks/lib/permission-rules.cjs +0 -170
  55. package/src/hooks/lib/settings-loader.cjs +0 -112
  56. package/src/lib/hook-pipe-path.cjs +0 -10
  57. package/src/runtime/channels/lib/hook-pipe-server.mjs +0 -671
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Standalone mixdog coding-agent CLI/TUI workspace.",
@@ -57,6 +57,7 @@
57
57
  "@modelcontextprotocol/sdk": "^1.29.0",
58
58
  "@mozilla/readability": "^0.6.0",
59
59
  "chalk": "^5.6.2",
60
+ "cli-highlight": "^2.1.11",
60
61
  "diff": "^9.0.0",
61
62
  "discord.js": "^14.26.4",
62
63
  "ink": "^7.1.0",
@@ -72,8 +73,7 @@
72
73
  "undici": "^8.5.0",
73
74
  "wrap-ansi": "^10.0.0",
74
75
  "ws": "^8.21.0",
75
- "zod": "^3.25.76",
76
- "zod-to-json-schema": "^3.25.2"
76
+ "zod": "^3.25.76"
77
77
  },
78
78
  "overrides": {
79
79
  "discord.js": {
@@ -15,8 +15,8 @@ function assert(condition, message) {
15
15
  // --- Role normalization ---
16
16
  assert(normalizeIngestRole('human') === 'user', 'human should normalize to user');
17
17
  assert(normalizeIngestRole('AI') === 'assistant', 'AI should normalize to assistant');
18
- assert(normalizeIngestRole('tool_result') === 'tool', 'tool_result should normalize to tool');
19
- assert(normalizeIngestRole('developer') === 'developer', 'developer should be preserved');
18
+ assert(normalizeIngestRole('tool_result') === null, 'tool_result is not ingested (maps to tool, dropped)');
19
+ assert(normalizeIngestRole('developer') === null, 'developer is not ingested');
20
20
  assert(normalizeIngestRole('mystery') === null, 'unknown role should be dropped');
21
21
 
22
22
  // --- Stable source_ref across index/time for untimestamped messages ---
@@ -25,7 +25,7 @@ function makeTag(role) {
25
25
  .replace(/^-+|-+$/g, '');
26
26
  }
27
27
 
28
- export function buildHeadlessSpawnArgs({ role, tag, cwd, message, provider, model } = {}) {
28
+ function buildHeadlessSpawnArgs({ role, tag, cwd, message, provider, model } = {}) {
29
29
  const spawnArgs = {
30
30
  type: 'spawn',
31
31
  role: clean(role),
@@ -126,31 +126,9 @@ function appendSessionStartCriticalLog(dataDir, line) {
126
126
  } catch { /* best-effort */ }
127
127
  }
128
128
 
129
- /**
130
- * Fallback when call sites omit `{ critical: true }`.
131
- */
132
- function sessionStartCriticalFallback(line) {
133
- const s = String(line || '');
134
- if (/reason=ok\b/.test(s)) return false;
135
- if (/\[session-start\] skip\b/.test(s) && /reason=/.test(s)) return true;
136
- if (/result=null/.test(s)) return true;
137
- if (/owner route unavailable/.test(s)) return true;
138
- if (/memory_port unavailable/.test(s)) return true;
139
- if (/\baborted\b/i.test(s)) return true;
140
- if (/\bcycle1\b/.test(s) && /reason=/.test(s) && !/reason=ok\b/.test(s)) return true;
141
- if (/\b(exception|failed|abort|err=|non-200|missing-dirs|catch endpoint)\b/i.test(s)) {
142
- return true;
143
- }
144
- return false;
145
- }
146
-
147
129
  module.exports = {
148
- isTruthyEnv,
149
130
  isMixdogDebugEnabled,
150
- isStalePluginLogSibling,
151
131
  pruneStalePluginDataLogSiblings,
152
132
  appendSessionStartCriticalLog,
153
- sessionStartCriticalFallback,
154
133
  DEFAULT_STALE_LOG_SIBLING_MAX,
155
- SESSION_START_CRITICAL_LOG,
156
134
  };
@@ -11,18 +11,12 @@
11
11
  const path = require('path');
12
12
  const os = require('os');
13
13
 
14
- const DEFAULT_ROOT = path.join(__dirname, '..');
15
-
16
14
  function mixdogHome() {
17
15
  return process.env.MIXDOG_HOME || path.join(os.homedir(), '.mixdog');
18
16
  }
19
17
 
20
- function mixdogRoot() {
21
- return process.env.MIXDOG_ROOT || DEFAULT_ROOT;
22
- }
23
-
24
18
  function resolvePluginData() {
25
19
  return process.env.MIXDOG_DATA_DIR || path.join(mixdogHome(), 'data');
26
20
  }
27
21
 
28
- module.exports = { resolvePluginData, mixdogHome, mixdogRoot };
22
+ module.exports = { resolvePluginData };
@@ -26,8 +26,8 @@
26
26
  * - agent/00-common.md — agent common behavior + universal worker contract (BP2)
27
27
  * - agent/10..50-*.md — per-hidden-role bodies (consumed by loadScopedRoleInstructions)
28
28
  *
29
- * Core memory snapshot and session recap are injected separately by
30
- * hooks/session-start.cjs from the memory worker (pgdata) (Lead only).
29
+ * Core memory snapshot is injected separately from the memory worker (pgdata)
30
+ * (Lead only).
31
31
  */
32
32
 
33
33
  const fs = require('fs');
@@ -2856,7 +2856,6 @@ export async function createMixdogSessionRuntime({
2856
2856
  // context (see composeSystemPrompt), so there is nothing prompt-side to
2857
2857
  // refresh either. `markRefresh`/`changed` are kept only for signature
2858
2858
  // compatibility with existing callers.
2859
- void changed;
2860
2859
  void markRefresh;
2861
2860
  // Lazy mode: before the first turn (e.g. the initial project-selection
2862
2861
  // cwd set), do NOT prewarm — that is exactly the post-first-frame freeze
package/src/repl.mjs CHANGED
@@ -59,8 +59,6 @@ async function loadShutdownModule() {
59
59
  return shutdownModulePromise;
60
60
  }
61
61
 
62
- export { printHelp };
63
-
64
62
  export async function runRepl({ provider: providerName, model, toolMode = 'full' } = {}) {
65
63
  // `--help` short-circuits before any provider init so the smoke test (which
66
64
  // invokes `src/cli.mjs --help`) gets clean help output and a 0 exit. We read
@@ -21,7 +21,7 @@
21
21
  *
22
22
  * Kind classification:
23
23
  * - 'retrieval' : short-lived hidden retrieval roles (explore).
24
- * - 'maintenance' : background-trigger hidden roles (memory cycle, recap, scheduler,
24
+ * - 'maintenance' : background-trigger hidden roles (memory cycle, scheduler,
25
25
  * webhook). Receive only their own self section in BP2.
26
26
  *
27
27
  * Permission classification:
@@ -30,8 +30,11 @@ import {
30
30
  } from '../stall-policy.mjs';
31
31
  import {
32
32
  classifyError,
33
+ classifyMidstreamError,
33
34
  midstreamBackoffFor,
35
+ MIDSTREAM_RETRY_POLICY,
34
36
  retryAfterMsFromError,
37
+ sleepWithAbort,
35
38
  withRetry,
36
39
  } from './retry-classifier.mjs';
37
40
  import { buildAnthropicBetaHeaders, supportsAnthropicFastMode } from './anthropic-betas.mjs';
@@ -46,8 +49,19 @@ const MODEL_CACHE_TTL_MS = 24 * 60 * 60_000;
46
49
  // SSE progress emits (per-request "Response …" and "Done:" lines). Off by default.
47
50
  const SSE_VERBOSE = process.env.MIXDOG_SSE_VERBOSE === '1';
48
51
 
49
- /** Bounded mid-stream SSE retries (transient stream loss); shared with anthropic.mjs. */
50
- export const ANTHROPIC_MAX_MIDSTREAM_RETRIES = 3;
52
+ /** Bounded mid-stream SSE retries (transient stream loss); shared with anthropic.mjs.
53
+ * Sourced from the single shared retry-budget table (MIDSTREAM_RETRY_POLICY.sse). */
54
+ export const ANTHROPIC_MAX_MIDSTREAM_RETRIES = MIDSTREAM_RETRY_POLICY.sse.defaultRetries;
55
+
56
+ // Policy passed to the shared classifyMidstreamError for the SSE path. The
57
+ // top-of-function attempt-budget gate uses defaultRetries (3); perClassifierGate
58
+ // is false so the classifier returns raw bucket strings (the loop owns the
59
+ // MAX_MIDSTREAM_RETRIES bound), matching the former _classifyMidstreamError.
60
+ const SSE_MIDSTREAM_POLICY = {
61
+ mode: 'sse',
62
+ defaultRetries: MIDSTREAM_RETRY_POLICY.sse.defaultRetries,
63
+ perClassifierGate: false,
64
+ };
51
65
 
52
66
  function formatRetryAfter(ms) {
53
67
  const n = Number(ms);
@@ -889,25 +903,10 @@ function _captureMidstreamAbort(state, reason) {
889
903
  }
890
904
  }
891
905
 
892
- async function _midstreamSleepWithAbort(ms, signal) {
893
- if (!ms) return;
894
- if (!signal) {
895
- await new Promise((r) => setTimeout(r, ms));
896
- return;
897
- }
898
- await new Promise((resolve, reject) => {
899
- const t = setTimeout(() => {
900
- try { signal.removeEventListener('abort', onAbort); } catch {}
901
- resolve();
902
- }, ms);
903
- const onAbort = () => {
904
- clearTimeout(t);
905
- const reason = signal.reason;
906
- reject(reason instanceof Error ? reason : new Error('Anthropic OAuth mid-stream retry backoff aborted'));
907
- };
908
- if (signal.aborted) { onAbort(); return; }
909
- signal.addEventListener('abort', onAbort, { once: true });
910
- });
906
+ // Abort-aware mid-stream backoff sleep → shared sleepWithAbort
907
+ // (retry-classifier.mjs). abortMessage preserves the prior fallback text.
908
+ function _midstreamSleepWithAbort(ms, signal) {
909
+ return sleepWithAbort(ms, signal, undefined, 'Anthropic OAuth mid-stream retry backoff aborted');
911
910
  }
912
911
 
913
912
  function _statusForAnthropicSseError(type, message) {
@@ -1258,35 +1257,14 @@ async function parseSSEStream(response, signal, abortStream, onStreamDelta, onTo
1258
1257
  * That keeps recovery limited to transport/stream stalls without risking
1259
1258
  * duplicate eager tool execution.
1260
1259
  */
1260
+ // Thin wrapper: the SSE mid-stream decision tree now lives in the shared
1261
+ // classifyMidstreamError (retry-classifier.mjs, policy.mode='sse'). Kept as a
1262
+ // named export so internal call sites AND anthropic.mjs (which imports this
1263
+ // symbol) keep resolving it. Behavior is byte-identical — the shared function
1264
+ // is the relocated original, gated by SSE_MIDSTREAM_POLICY (defaultRetries=3,
1265
+ // perClassifierGate:false).
1261
1266
  export function _classifyMidstreamError(err, state) {
1262
- if (!state) return null;
1263
- if ((state.attemptIndex | 0) >= ANTHROPIC_MAX_MIDSTREAM_RETRIES) return null;
1264
- if (state.sawCompleted) return null;
1265
- if (!state.sawMessageStart) return null;
1266
- if (state.userAbort) return null;
1267
- if (state.emittedToolCall) return null;
1268
-
1269
- if (!err) return null;
1270
- const status = Number(err?.httpStatus || 0);
1271
- if (status === 401 || status === 403 || status === 429) return null;
1272
-
1273
- const name = err?.name || '';
1274
- if (name === 'AgentStallAbortError') return 'agent_stall';
1275
- if (name === 'StreamStalledAbortError') return 'stream_stalled';
1276
- if (state.watchdogAbort === 'AgentStallAbortError') return 'agent_stall';
1277
- if (state.watchdogAbort === 'StreamStalledAbortError') return 'stream_stalled';
1278
-
1279
- const code = err?.code || err?.cause?.code || '';
1280
- if (code === 'ECONNRESET') return 'reset';
1281
- if (code === 'ETIMEDOUT' || code === 'ESOCKETTIMEDOUT') return 'timeout';
1282
- if (code === 'ENOTFOUND' || code === 'EAI_AGAIN' || code === 'EAI_NODATA') return 'dns';
1283
-
1284
- const msg = String(err?.message || '').toLowerCase();
1285
- if (msg.includes('stream timed out after') && msg.includes('of inactivity')) return 'sse_idle_timeout';
1286
- if (msg.includes('body stream') && msg.includes('terminated')) return 'stream_terminated';
1287
- if (msg.includes('fetch failed')) return 'fetch_failed';
1288
-
1289
- return null;
1267
+ return classifyMidstreamError(err, state, SSE_MIDSTREAM_POLICY);
1290
1268
  }
1291
1269
 
1292
1270
  // --- Build request body ---
@@ -1,7 +1,7 @@
1
1
  import Anthropic from '@anthropic-ai/sdk';
2
2
  import { loadConfig } from '../config.mjs';
3
3
  import { sanitizeToolPairs, sanitizeAnthropicContentPairs } from '../session/context-utils.mjs';
4
- import { classifyError, midstreamBackoffFor, withRetry } from './retry-classifier.mjs';
4
+ import { classifyError, midstreamBackoffFor, sleepWithAbort, withRetry } from './retry-classifier.mjs';
5
5
  import { traceAgentUsage } from '../agent-trace.mjs';
6
6
  import {
7
7
  PROVIDER_FIRST_BYTE_TIMEOUT_MS,
@@ -19,25 +19,10 @@ import { normalizeContentForAnthropic } from './media-normalization.mjs';
19
19
  import { enrichModels } from './model-catalog.mjs';
20
20
  import { getLlmDispatcher } from '../../../shared/llm/http-agent.mjs';
21
21
 
22
- async function _midstreamSleepWithAbort(ms, signal) {
23
- if (!ms) return;
24
- if (!signal) {
25
- await new Promise((r) => setTimeout(r, ms));
26
- return;
27
- }
28
- await new Promise((resolve, reject) => {
29
- const t = setTimeout(() => {
30
- try { signal.removeEventListener('abort', onAbort); } catch {}
31
- resolve();
32
- }, ms);
33
- const onAbort = () => {
34
- clearTimeout(t);
35
- const reason = signal.reason;
36
- reject(reason instanceof Error ? reason : new Error('Anthropic mid-stream retry backoff aborted'));
37
- };
38
- if (signal.aborted) { onAbort(); return; }
39
- signal.addEventListener('abort', onAbort, { once: true });
40
- });
22
+ // Abort-aware mid-stream backoff sleep → shared sleepWithAbort
23
+ // (retry-classifier.mjs). abortMessage preserves the prior fallback text.
24
+ function _midstreamSleepWithAbort(ms, signal) {
25
+ return sleepWithAbort(ms, signal, undefined, 'Anthropic mid-stream retry backoff aborted');
41
26
  }
42
27
 
43
28
  // 4-BP cache policy aligned with anthropic-oauth — system + tier3 +
@@ -1,7 +1,7 @@
1
1
  import OpenAI from 'openai';
2
2
  import { createHash } from 'crypto';
3
3
  import { loadConfig } from '../config.mjs';
4
- import { withRetry } from './retry-classifier.mjs';
4
+ import { shouldFallbackTransport, withRetry } from './retry-classifier.mjs';
5
5
  import { sendViaWebSocket } from './openai-oauth-ws.mjs';
6
6
  import {
7
7
  consumeCompatChatCompletionStream,
@@ -246,33 +246,14 @@ function _envFlag(name, fallback = true) {
246
246
  return !['0', 'false', 'off', 'no'].includes(String(raw).toLowerCase());
247
247
  }
248
248
 
249
+ // xAI WS→HTTP transport fallback → shared shouldFallbackTransport
250
+ // (retry-classifier.mjs). Identical deny-order + allow-list; the per-provider
251
+ // env flag is computed here and passed via `enabled`.
249
252
  function _shouldFallbackXaiWsToHttp(err, signal) {
250
- if (!_envFlag('MIXDOG_XAI_WS_HTTP_FALLBACK', true)) return false;
251
- if (signal?.aborted) return false;
252
- if (err?.liveTextEmitted === true) return false;
253
- if (err?.emittedToolCall === true || err?.unsafeToRetry === true) return false;
254
- const status = Number(err?.httpStatus || err?.status || 0);
255
- if (status === 401 || status === 403 || status === 404 || status === 429) return false;
256
- if (status >= 500 && status < 600) return true;
257
- const code = String(err?.code || '');
258
- if (['EWSACQUIRETIMEOUT', 'ETIMEDOUT', 'ESOCKETTIMEDOUT', 'ECONNRESET', 'EAI_AGAIN', 'ENOTFOUND', 'EAI_NODATA', 'ECONNREFUSED', 'ENETUNREACH', 'EHOSTUNREACH', 'EPIPE'].includes(code)) {
259
- return true;
260
- }
261
- const classifier = String(err?.retryClassifier || err?.midstreamClassifier || '');
262
- if ([
263
- 'timeout', 'reset', 'dns', 'refused', 'network', 'acquire_timeout', 'http_5xx',
264
- 'first_byte_timeout', 'first_meaningful_timeout',
265
- 'ws_1006', 'ws_1011', 'ws_1012', 'ws_1000', 'ws_4000', 'agent_stall', 'stream_stalled',
266
- 'response_failed_disconnected', 'response_failed_network', 'response_failed_auth_expired',
267
- 'ws_send_failed',
268
- ].includes(classifier)) {
269
- return true;
270
- }
271
- if (/^http_5\d\d$/.test(classifier)) return true;
272
- if (err?.firstByteTimeout) return true;
273
- if (err?.firstMeaningfulTimeout) return true;
274
- const msg = String(err?.message || '');
275
- return /opening handshake has timed out|socket hang up|acquire timed out|no first server event|no meaningful output/i.test(msg);
253
+ return shouldFallbackTransport(err, {
254
+ signal,
255
+ enabled: _envFlag('MIXDOG_XAI_WS_HTTP_FALLBACK', true),
256
+ });
276
257
  }
277
258
 
278
259
  function useXaiResponsesWebSocketWarmup(opts, config, { previousResponseId, instructions, rawTools }) {
@@ -33,7 +33,15 @@ import {
33
33
  traceAgentUsage,
34
34
  appendAgentTrace,
35
35
  } from '../agent-trace.mjs';
36
- import { jitterDelayMs, populateHttpStatusFromMessage } from './retry-classifier.mjs';
36
+ import {
37
+ classifyHandshakeError,
38
+ classifyMidstreamError,
39
+ createStreamSafetyStamps,
40
+ jitterDelayMs,
41
+ MIDSTREAM_RETRY_POLICY,
42
+ populateHttpStatusFromMessage,
43
+ sleepWithAbort,
44
+ } from './retry-classifier.mjs';
37
45
  import { makeInvalidToolArgsMarker } from './openai-compat-stream.mjs';
38
46
  import {
39
47
  PROVIDER_RETRY_MAX_ATTEMPTS,
@@ -71,9 +79,18 @@ const WS_PRE_RESPONSE_CREATED_MS = (() => {
71
79
  })();
72
80
  // Inter-chunk inactivity after first meaningful output.
73
81
  const WS_INTER_CHUNK_MS = PROVIDER_WS_INTER_CHUNK_TIMEOUT_MS;
74
- const MIDSTREAM_WS_TRANSIENT_RETRY_LIMIT = 4;
75
- const MIDSTREAM_DEFAULT_RETRY_LIMIT = 2;
76
- const MIDSTREAM_BACKOFF_MS = [250, 1000, 2000, 4000];
82
+ // Mid-stream retry budgets + backoff now live in the shared MIDSTREAM_RETRY_POLICY
83
+ // table (retry-classifier.mjs). These aliases keep the local call sites readable
84
+ // and ensure the numbers exist in exactly ONE place.
85
+ const MIDSTREAM_WS_TRANSIENT_RETRY_LIMIT = MIDSTREAM_RETRY_POLICY.ws.transientCloseRetries;
86
+ const MIDSTREAM_DEFAULT_RETRY_LIMIT = MIDSTREAM_RETRY_POLICY.ws.defaultRetries;
87
+ const MIDSTREAM_BACKOFF_MS = MIDSTREAM_RETRY_POLICY.ws.backoff;
88
+ // Policy object passed to the shared classifyMidstreamError for the WS path.
89
+ const WS_MIDSTREAM_POLICY = {
90
+ mode: 'ws',
91
+ transientCloseRetries: MIDSTREAM_RETRY_POLICY.ws.transientCloseRetries,
92
+ defaultRetries: MIDSTREAM_RETRY_POLICY.ws.defaultRetries,
93
+ };
77
94
 
78
95
  // Handshake retry policy. The `ws` library surfaces a bare
79
96
  // `Opening handshake has timed out` Error after handshakeTimeout; transient
@@ -1997,34 +2014,12 @@ export async function _streamResponse({
1997
2014
  * 'http_5xx' (with specific status e.g. 'http_503') — server overload
1998
2015
  * null — not retryable
1999
2016
  */
2017
+ // Thin re-export wrapper: handshake classification now lives in the shared
2018
+ // retry-classifier (classifyHandshakeError). Kept here as a named export so
2019
+ // internal call sites (_acquireWithRetry) and any external importer keep
2020
+ // resolving the same symbol.
2000
2021
  export function _classifyHandshakeError(err) {
2001
- if (!err) return null;
2002
- const code = err.code || '';
2003
- const msg = String(err.message || '');
2004
- const status = Number(err.httpStatus || 0);
2005
-
2006
- // Permanent HTTP (auth / quota / not-found) short-circuits.
2007
- if (status === 401 || status === 403 || status === 404 || status === 429) {
2008
- return null;
2009
- }
2010
- // 5xx transient.
2011
- if (status >= 500 && status < 600) {
2012
- return `http_${status}`;
2013
- }
2014
-
2015
- // Node errno codes.
2016
- if (code === 'ECONNRESET') return 'reset';
2017
- if (code === 'EAI_AGAIN' || code === 'ENOTFOUND' || code === 'EAI_NODATA') return 'dns';
2018
- if (code === 'ECONNREFUSED') return 'refused';
2019
- if (code === 'ETIMEDOUT' || code === 'ESOCKETTIMEDOUT') return 'timeout';
2020
- if (code === 'EWSACQUIRETIMEOUT') return 'acquire_timeout';
2021
- if (code === 'ENETUNREACH' || code === 'EHOSTUNREACH' || code === 'EPIPE') return 'network';
2022
-
2023
- // `ws` library's handshake-timeout path: thrown as a bare Error.
2024
- if (/opening handshake has timed out/i.test(msg)) return 'timeout';
2025
- if (/socket hang up/i.test(msg)) return 'reset';
2026
-
2027
- return null;
2022
+ return classifyHandshakeError(err);
2028
2023
  }
2029
2024
 
2030
2025
  /**
@@ -2065,128 +2060,25 @@ export function _classifyHandshakeError(err) {
2065
2060
  * - HTTP 401 / 403 / 429 surfaced on the error
2066
2061
  * - state.attemptIndex has reached the classifier-specific retry budget
2067
2062
  */
2063
+ // Thin wrapper: the full WS mid-stream decision tree now lives in the shared
2064
+ // classifyMidstreamError (retry-classifier.mjs, policy.mode='ws'). Kept as a
2065
+ // named export so internal call sites and any external importer keep resolving
2066
+ // the same symbol. Behavior is byte-identical — the shared function is the
2067
+ // relocated original, with the per-classifier budget gating supplied by
2068
+ // WS_MIDSTREAM_POLICY (transientCloseRetries=4, defaultRetries=2).
2068
2069
  export function _classifyMidstreamError(err, state) {
2069
- if (!state) return null;
2070
- const attemptIndex = state.attemptIndex | 0;
2071
- // Already completed (shouldn't throw, but defensive).
2072
- if (state.sawCompleted) return null;
2073
- // Any tool call already surfaced to the caller — retrying would
2074
- // normally duplicate the side effect. EXCEPTION: ws_1000 truncation
2075
- // (server-side normal close after response.created, before completion)
2076
- // leaves the caller with an orphaned tool_use that the next turn cannot
2077
- // pair to a tool_result, which the provider rejects with a hard 400.
2078
- // The duplicate-side-effect risk is preferable to deterministic worker
2079
- // death, especially for detached agents that re-dispatch idempotently.
2080
- if (state.emittedToolCall) {
2081
- const _cc = Number(err?.wsCloseCode || state.wsCloseCode || 0);
2082
- if (!(_cc === 1000 && state.sawResponseCreated && !state.sawCompleted)) return null;
2083
- }
2084
- // Live-text invariant: once a non-empty text chunk has been relayed to the
2085
- // client (gateway live mode), the rendered output cannot be withdrawn and a
2086
- // retry would concatenate a second attempt. Treat every subsequent
2087
- // mid-stream/truncated failure as final — never retry.
2088
- if (state.emittedText || err?.liveTextEmitted) return null;
2089
- // Post-upgrade-no-first-event: the socket opened, our response.create
2090
- // frame was sent, but the server never emitted a single event before
2091
- // the short pre-`response.created` watchdog fired. The handshake retry
2092
- // layer only sees pre-upgrade failures and the legacy pre-stream gate
2093
- // below would deny this case (sawResponseCreated === false). Tag it
2094
- // here as a fast retryable bucket so the worker reconnects within
2095
- // seconds instead of stalling for the full first-meaningful window.
2096
- if (state.firstByteTimeout || err?.firstByteTimeout) {
2097
- return _allowMidstreamRetry('first_byte_timeout', attemptIndex);
2098
- }
2099
- if (state.firstMeaningfulTimeout || err?.firstMeaningfulTimeout) {
2100
- return _allowMidstreamRetry('first_meaningful_timeout', attemptIndex);
2101
- }
2102
- // _sendFrame failure (socket not OPEN, send callback errored, JSON
2103
- // serialize threw). Always retryable: caller will forceFresh next
2104
- // attempt so the wedged socket is dropped.
2105
- if (err?.wsSendFailed || state.wsSendFailed) {
2106
- return _allowMidstreamRetry('ws_send_failed', attemptIndex);
2107
- }
2108
- // Pre-stream failures normally belong to the handshake retry layer. BUT
2109
- // WS close 1011 / 1012 can fire after the 101 upgrade but BEFORE the
2110
- // first response.created event when the server's keepalive times out or
2111
- // the service restarts. Neither the handshake retry layer (it only sees
2112
- // pre-upgrade failures) nor the existing mid-stream gate covers this
2113
- // window, so permit bounded retry here for those two codes only.
2114
- if (!state.sawResponseCreated) {
2115
- const closeCode = Number(err?.wsCloseCode || state.wsCloseCode || 0);
2116
- if (closeCode !== 1011 && closeCode !== 1012) return null;
2117
- }
2118
- // User/caller abort — never retry.
2119
- if (state.userAbort) return null;
2120
-
2121
- if (!err) return null;
2122
- const status = Number(err?.httpStatus || 0);
2123
- if (status === 401 || status === 403 || status === 429) return null;
2124
- // Transient 5xx surfaced via populateHttpStatusFromMessage (case 'error'
2125
- // and case 'response.failed' branches sniff server-supplied text like
2126
- // "Our servers are currently overloaded" and assign httpStatus=503).
2127
- // Allow one bounded mid-stream retry on the same budget as the WS close-
2128
- // code buckets above so server-side overload no longer leaks straight
2129
- // to the caller without a single retry attempt.
2130
- if (status >= 500 && status < 600) {
2131
- return _allowMidstreamRetry(`http_${status}`, attemptIndex);
2132
- }
2133
-
2134
- const name = err?.name || '';
2135
- if (name === 'AgentStallAbortError') return _allowMidstreamRetry('agent_stall', attemptIndex);
2136
- if (name === 'StreamStalledAbortError') return _allowMidstreamRetry('stream_stalled', attemptIndex);
2137
-
2138
- // Watchdog abort surfaced via externalSignal handler → err is the reason
2139
- // itself. state.watchdogAbort captures the class name when the error
2140
- // shape was preserved but the name was stripped by some wrapper.
2141
- if (state.watchdogAbort === 'AgentStallAbortError') return _allowMidstreamRetry('agent_stall', attemptIndex);
2142
- if (state.watchdogAbort === 'StreamStalledAbortError') return _allowMidstreamRetry('stream_stalled', attemptIndex);
2143
-
2144
- // WS close codes: prefer the decorated property, fall back to state.
2145
- const closeCode = Number(err?.wsCloseCode || state.wsCloseCode || 0);
2146
- if (closeCode === 1006) return _allowMidstreamRetry('ws_1006', attemptIndex);
2147
- if (closeCode === 1011) return _allowMidstreamRetry('ws_1011', attemptIndex);
2148
- if (closeCode === 1012) return _allowMidstreamRetry('ws_1012', attemptIndex);
2149
- // Private 4xxx codes from a server/proxy are auth/policy/application closes;
2150
- // never treat them as transient. 4000 is our local pre-stream watchdog code.
2151
- if (closeCode >= 4000 && closeCode < 5000 && closeCode !== 4000) return null;
2152
- if (closeCode === 4000) return _allowMidstreamRetry('ws_4000', attemptIndex);
2153
- // Server-side normal close (1000) AFTER response.created but BEFORE
2154
- // response.completed = truncated stream; legitimate transient. The
2155
- // pre-stream gate above already rejects 1000 before sawResponseCreated
2156
- // (handshake retry layer owns that window).
2157
- if (closeCode === 1000 && state.sawResponseCreated && !state.sawCompleted) return _allowMidstreamRetry('ws_1000', attemptIndex);
2158
-
2159
- // response.failed payload mentioning network_error / stream_disconnected.
2160
- // xAI's gRPC backend periodically rotates auth context (server-side TTL)
2161
- // and surfaces "Auth context expired" as a response.failed event. The
2162
- // attemptIndex > 0 path in sendViaWebSocket forces a fresh WS handshake,
2163
- // which re-authenticates — so a single bounded retry recovers the turn
2164
- // instead of letting the worker die mid-session.
2165
- const failed = err?.responseFailed || state.responseFailedPayload;
2166
- if (failed) {
2167
- try {
2168
- const blob = JSON.stringify(failed).toLowerCase();
2169
- if (blob.includes('stream_disconnected')) return _allowMidstreamRetry('response_failed_disconnected', attemptIndex);
2170
- if (blob.includes('network_error')) return _allowMidstreamRetry('response_failed_network', attemptIndex);
2171
- if (blob.includes('auth context expired')) return _allowMidstreamRetry('response_failed_auth_expired', attemptIndex);
2172
- } catch {}
2173
- }
2174
-
2175
- // Unknown → default-deny (don't risk a second full-cost turn for an error
2176
- // class we haven't proven is transient).
2177
- return null;
2070
+ return classifyMidstreamError(err, state, WS_MIDSTREAM_POLICY);
2178
2071
  }
2179
2072
 
2073
+ // Per-classifier retry budget, used by the sendViaWebSocket loop to bound the
2074
+ // attempt count once classifyMidstreamError returns a bucket. Mirrors the
2075
+ // shared _midstreamLimitFor(ws) — the numbers come from MIDSTREAM_RETRY_POLICY.
2180
2076
  function _midstreamRetryLimit(classifier) {
2181
2077
  return classifier === 'ws_1006' || classifier === 'ws_1011'
2182
2078
  ? MIDSTREAM_WS_TRANSIENT_RETRY_LIMIT
2183
2079
  : MIDSTREAM_DEFAULT_RETRY_LIMIT;
2184
2080
  }
2185
2081
 
2186
- function _allowMidstreamRetry(classifier, attemptIndex) {
2187
- return attemptIndex < _midstreamRetryLimit(classifier) ? classifier : null;
2188
- }
2189
-
2190
2082
  function _midstreamBackoffFor(retryNumber) {
2191
2083
  const raw = MIDSTREAM_BACKOFF_MS[Math.min(Math.max(retryNumber, 1), MIDSTREAM_BACKOFF_MS.length) - 1];
2192
2084
  return jitterDelayMs(raw);
@@ -2200,25 +2092,11 @@ function _backoffFor(attempt) {
2200
2092
 
2201
2093
  const _defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
2202
2094
 
2203
- async function _sleepWithAbort(ms, externalSignal, sleepFn = _defaultSleep) {
2204
- if (!ms) return;
2205
- if (!externalSignal) {
2206
- await sleepFn(ms);
2207
- return;
2208
- }
2209
- await new Promise((resolve, reject) => {
2210
- const t = setTimeout(() => {
2211
- externalSignal.removeEventListener('abort', onAbort);
2212
- resolve();
2213
- }, ms);
2214
- const onAbort = () => {
2215
- clearTimeout(t);
2216
- const reason = externalSignal.reason;
2217
- reject(reason instanceof Error ? reason : new Error('OpenAI OAuth WS retry backoff aborted'));
2218
- };
2219
- if (externalSignal.aborted) { onAbort(); return; }
2220
- externalSignal.addEventListener('abort', onAbort, { once: true });
2221
- });
2095
+ // Abort-aware backoff sleep shared sleepWithAbort (retry-classifier.mjs). The
2096
+ // abortMessage preserves the prior fallback text when the abort reason is not an
2097
+ // Error; _sleepFn (test seam) is threaded through as the no-signal sleep impl.
2098
+ function _sleepWithAbort(ms, externalSignal, sleepFn = _defaultSleep) {
2099
+ return sleepWithAbort(ms, externalSignal, sleepFn, 'OpenAI OAuth WS retry backoff aborted');
2222
2100
  }
2223
2101
 
2224
2102
  /**
@@ -2379,23 +2257,15 @@ export async function sendViaWebSocket({
2379
2257
  // already-rendered output. A text-emitting attempt is never retry-eligible
2380
2258
  // (_classifyMidstreamError returns null on emittedText), so the surfaced
2381
2259
  // error is frequently an EARLIER attempt's firstAttemptError that never saw
2382
- // the marker; _stampLiveText re-applies it on every throw path.
2383
- let liveTextEmittedAcrossAttempts = false;
2384
- const _stampLiveText = (e) => {
2385
- if (liveTextEmittedAcrossAttempts && e) {
2386
- try { e.liveTextEmitted = true; e.unsafeToRetry = true; } catch {}
2387
- }
2388
- return e;
2389
- };
2390
- // Tool-call relay invariant: same latch pattern as live text — once a tool
2391
- // call was forwarded, surfaced errors must block HTTP fallback / reissue.
2392
- let toolEmittedAcrossAttempts = false;
2393
- const _stampTool = (e) => {
2394
- if (toolEmittedAcrossAttempts && e) {
2395
- try { e.emittedToolCall = true; e.unsafeToRetry = true; } catch {}
2396
- }
2397
- return e;
2398
- };
2260
+ // the marker; stampText re-applies it on every throw path. The latch state
2261
+ // + stamp semantics now come from the shared createStreamSafetyStamps()
2262
+ // factory (retry-classifier.mjs) identical to the former _stampLiveText /
2263
+ // _stampTool closures. markText()/markTool() set the latch (replacing the
2264
+ // liveTextEmittedAcrossAttempts / toolEmittedAcrossAttempts booleans);
2265
+ // stampText/stampTool re-apply the markers on every throw.
2266
+ const _safetyStamps = createStreamSafetyStamps();
2267
+ const _stampLiveText = _safetyStamps.stampText;
2268
+ const _stampTool = _safetyStamps.stampTool;
2399
2269
  // Server-side xAI conversation anchor preserved across mid-stream
2400
2270
  // retries. xAI keys its conversation by previous_response_id alone
2401
2271
  // (sessionToken is null for xAI in _mintSessionToken); a forceFresh
@@ -2665,10 +2535,10 @@ export async function sendViaWebSocket({
2665
2535
  // Latch across attempts: even though THIS error is never
2666
2536
  // retry-eligible once text is out, a later/earlier surfaced
2667
2537
  // error (firstAttemptError) must still carry the marker.
2668
- liveTextEmittedAcrossAttempts = true;
2538
+ _safetyStamps.markText();
2669
2539
  }
2670
2540
  if (midState.emittedToolCall) {
2671
- toolEmittedAcrossAttempts = true;
2541
+ _safetyStamps.markTool();
2672
2542
  }
2673
2543
  _stampLiveText(err);
2674
2544
  _stampTool(err);