mixdog 0.9.37 → 0.9.39

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 (127) hide show
  1. package/package.json +9 -4
  2. package/scripts/abort-recovery-test.mjs +43 -2
  3. package/scripts/agent-tag-reuse-smoke.mjs +146 -6
  4. package/scripts/agent-terminal-reap-test.mjs +127 -0
  5. package/scripts/agent-trace-io-test.mjs +69 -0
  6. package/scripts/code-graph-disk-hit-test.mjs +224 -0
  7. package/scripts/dispatch-persist-recovery-test.mjs +141 -0
  8. package/scripts/execution-completion-dedup-test.mjs +157 -0
  9. package/scripts/execution-pending-resume-kick-test.mjs +57 -2
  10. package/scripts/execution-resume-esc-integration-test.mjs +174 -0
  11. package/scripts/explore-bench.mjs +101 -8
  12. package/scripts/explore-prompt-policy-test.mjs +156 -11
  13. package/scripts/find-fuzzy-hidden-test.mjs +122 -0
  14. package/scripts/internal-comms-bench-test.mjs +226 -0
  15. package/scripts/internal-comms-bench.mjs +185 -58
  16. package/scripts/internal-comms-smoke.mjs +171 -23
  17. package/scripts/live-worker-smoke.mjs +38 -2
  18. package/scripts/memory-cycle-routing-test.mjs +111 -0
  19. package/scripts/memory-rule-contract-test.mjs +93 -0
  20. package/scripts/notify-completion-mirror-test.mjs +73 -0
  21. package/scripts/output-style-smoke.mjs +2 -2
  22. package/scripts/rg-runner-test.mjs +240 -0
  23. package/scripts/routing-corpus-test.mjs +349 -0
  24. package/scripts/routing-corpus.mjs +211 -32
  25. package/scripts/session-orphan-sweep-test.mjs +83 -0
  26. package/scripts/session-sweep.mjs +266 -0
  27. package/scripts/steering-drain-buckets-test.mjs +179 -0
  28. package/scripts/tool-smoke.mjs +21 -13
  29. package/scripts/tool-tui-presentation-test.mjs +202 -0
  30. package/src/agents/heavy-worker/AGENT.md +10 -7
  31. package/src/agents/reviewer/AGENT.md +6 -4
  32. package/src/agents/worker/AGENT.md +7 -5
  33. package/src/rules/agent/00-common.md +4 -4
  34. package/src/rules/agent/00-core.md +11 -14
  35. package/src/rules/agent/20-skip-protocol.md +3 -3
  36. package/src/rules/agent/30-explorer.md +56 -48
  37. package/src/rules/agent/40-cycle1-agent.md +15 -24
  38. package/src/rules/agent/41-cycle2-agent.md +33 -57
  39. package/src/rules/agent/42-cycle3-agent.md +28 -42
  40. package/src/rules/lead/01-general.md +7 -10
  41. package/src/rules/lead/lead-brief.md +11 -14
  42. package/src/rules/lead/lead-tool.md +6 -5
  43. package/src/rules/shared/01-tool.md +44 -41
  44. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +37 -1
  45. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +20 -5
  46. package/src/runtime/agent/orchestrator/dispatch-persist.mjs +31 -8
  47. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +13 -7
  48. package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +21 -1
  49. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +6 -6
  50. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +17 -38
  51. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +5 -4
  52. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +119 -3
  53. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +17 -8
  54. package/src/runtime/agent/orchestrator/session/context-utils.mjs +270 -78
  55. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +2 -1
  56. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +18 -52
  57. package/src/runtime/agent/orchestrator/session/manager/delivered-completions.mjs +123 -0
  58. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +15 -2
  59. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +41 -2
  60. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +1 -1
  61. package/src/runtime/agent/orchestrator/session/store.mjs +339 -63
  62. package/src/runtime/agent/orchestrator/stall-policy.mjs +37 -0
  63. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +12 -1
  64. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +25 -20
  65. package/src/runtime/agent/orchestrator/tools/builtin/fs-reachability.mjs +6 -2
  66. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +118 -53
  67. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +106 -29
  68. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +8 -1
  69. package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +4 -2
  70. package/src/runtime/agent/orchestrator/tools/builtin/read-range-index.mjs +3 -1
  71. package/src/runtime/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +4 -1
  72. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +33 -2
  73. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +151 -64
  74. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +37 -13
  75. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +84 -49
  76. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +172 -23
  77. package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +3 -0
  78. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +68 -5
  79. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +17 -3
  80. package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +24 -10
  81. package/src/runtime/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +6 -3
  82. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +4 -7
  83. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +1 -0
  84. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +1 -1
  85. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +4 -4
  86. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +4 -3
  87. package/src/runtime/memory/lib/memory-cycle3.mjs +12 -2
  88. package/src/runtime/shared/tool-primitives.mjs +4 -1
  89. package/src/runtime/shared/tool-status.mjs +27 -0
  90. package/src/runtime/shared/tool-surface.mjs +6 -3
  91. package/src/session-runtime/config-helpers.mjs +14 -0
  92. package/src/session-runtime/context-status.mjs +1 -0
  93. package/src/session-runtime/effort.mjs +6 -2
  94. package/src/session-runtime/model-recency.mjs +5 -2
  95. package/src/session-runtime/provider-models.mjs +3 -3
  96. package/src/session-runtime/runtime-core.mjs +78 -10
  97. package/src/session-runtime/tool-catalog.mjs +34 -0
  98. package/src/session-runtime/warmup-schedulers.mjs +7 -1
  99. package/src/standalone/agent-tool/notify.mjs +13 -0
  100. package/src/standalone/agent-tool.mjs +45 -69
  101. package/src/standalone/explore-tool.mjs +6 -7
  102. package/src/tui/App.jsx +31 -0
  103. package/src/tui/app/model-options.mjs +5 -3
  104. package/src/tui/app/model-picker.mjs +12 -24
  105. package/src/tui/app/transcript-window.mjs +1 -0
  106. package/src/tui/components/ToolExecution.jsx +11 -6
  107. package/src/tui/components/TranscriptItem.jsx +1 -1
  108. package/src/tui/components/tool-execution/surface-detail.mjs +24 -10
  109. package/src/tui/components/tool-execution/text-format.mjs +10 -19
  110. package/src/tui/dist/index.mjs +533 -143
  111. package/src/tui/engine/agent-job-feed.mjs +153 -16
  112. package/src/tui/engine/agent-response-tail.mjs +68 -0
  113. package/src/tui/engine/notification-plan.mjs +16 -0
  114. package/src/tui/engine/queue-helpers.mjs +8 -0
  115. package/src/tui/engine/session-api.mjs +8 -2
  116. package/src/tui/engine/session-flow.mjs +34 -2
  117. package/src/tui/engine/tool-card-results.mjs +54 -32
  118. package/src/tui/engine/tool-result-status.mjs +75 -21
  119. package/src/tui/engine/turn.mjs +83 -43
  120. package/src/tui/engine.mjs +63 -2
  121. package/src/workflows/bench/WORKFLOW.md +25 -35
  122. package/src/workflows/default/WORKFLOW.md +38 -32
  123. package/src/workflows/solo/WORKFLOW.md +19 -22
  124. package/scripts/_jitter-fuzz.mjs +0 -44410
  125. package/scripts/_jitter-fuzz2.mjs +0 -44400
  126. package/scripts/_jitter-probe.mjs +0 -44397
  127. package/scripts/_jp2.mjs +0 -45614
@@ -26,6 +26,7 @@ let _localTracePath = null;
26
26
  let _localTraceBuffer = [];
27
27
  let _localTraceTimer = null;
28
28
  let _localTraceFlushInFlight = false;
29
+ let _localTraceFlushPromise = null;
29
30
  let _toolFailurePath = null;
30
31
  let _toolFailureBuffer = [];
31
32
  let _toolFailureTimer = null;
@@ -132,12 +133,10 @@ function _flushLocalTrace() {
132
133
  clearTimeout(_localTraceTimer);
133
134
  _localTraceTimer = null;
134
135
  }
135
- if (_localTraceBuffer.length === 0) return;
136
136
  if (_localTraceFlushInFlight) {
137
- _localTraceTimer = setTimeout(_flushLocalTrace, 25);
138
- _localTraceTimer.unref?.();
139
- return;
137
+ return _localTraceFlushPromise;
140
138
  }
139
+ if (_localTraceBuffer.length === 0) return null;
141
140
  const path = _resolveLocalTracePath();
142
141
  if (!path) return;
143
142
  const chunk = _localTraceBuffer.join('');
@@ -157,17 +156,20 @@ function _flushLocalTrace() {
157
156
  // mode only applies on file creation; existing files keep their mode.
158
157
  // Windows ignores POSIX bits — ACL governs there.
159
158
  _localTraceFlushInFlight = true;
160
- appendFile(path, chunk, { encoding: 'utf8', mode: 0o600 })
159
+ const pending = appendFile(path, chunk, { encoding: 'utf8', mode: 0o600 })
161
160
  .catch((err) => {
162
161
  warnAgentOnce('agent-trace:local-spool', `[agent-trace] local spool failed (${err?.message})`);
163
162
  })
164
163
  .finally(() => {
165
164
  _localTraceFlushInFlight = false;
165
+ _localTraceFlushPromise = null;
166
166
  if (_localTraceBuffer.length > 0) {
167
167
  _localTraceTimer = setTimeout(_flushLocalTrace, 0);
168
168
  _localTraceTimer.unref?.();
169
169
  }
170
170
  });
171
+ _localTraceFlushPromise = pending;
172
+ return pending;
171
173
  }
172
174
 
173
175
  function _flushLocalTraceSync() {
@@ -294,7 +296,20 @@ function _scheduleFlush(immediate = false) {
294
296
  }
295
297
  }
296
298
 
299
+ async function _drainLocalTrace() {
300
+ if (!_resolveLocalTracePath()) return;
301
+ if (_localTraceTimer) {
302
+ clearTimeout(_localTraceTimer);
303
+ _localTraceTimer = null;
304
+ }
305
+ while (_localTraceBuffer.length > 0 || _localTraceFlushInFlight) {
306
+ const pending = _flushLocalTrace();
307
+ if (pending) await pending;
308
+ }
309
+ }
310
+
297
311
  async function drainAgentTrace() {
312
+ await _drainLocalTrace();
298
313
  if (!_resolveServiceUrl()) return;
299
314
  if (_flushTimer) { clearTimeout(_flushTimer); _flushTimer = null; }
300
315
  for (let i = 0; i < 10 && _buffer.length > 0; i++) {
@@ -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 {
@@ -16,7 +16,10 @@
16
16
  import os from 'node:os';
17
17
 
18
18
  // Offline fallback only; live value refreshes from npm (24h TTL, in-process).
19
- export const CODEX_CLIENT_VERSION_FLOOR = '0.142.5';
19
+ // The backend gates model exposure AND per-request model access on the client
20
+ // version (gpt-5.6-* require >= 0.144.0 per codex models-manager/models.json,
21
+ // verified 2026-07-09), so keep this at the current release when bumping.
22
+ export const CODEX_CLIENT_VERSION_FLOOR = '0.144.1';
20
23
  const VERSION_TTL_MS = 24 * 60 * 60_000;
21
24
  let _cache = { value: null, fetchedAt: 0 };
22
25
  let _refreshInFlight = null;
@@ -52,6 +55,23 @@ export function codexClientVersionSync() {
52
55
  return _cache.value || CODEX_CLIENT_VERSION_FLOOR;
53
56
  }
54
57
 
58
+ /**
59
+ * Awaitable warmup for cold-start paths: resolves the live npm version (or
60
+ * floor on failure) and fills the shared cache so codexClientVersionSync()
61
+ * and codexVersionHeader() stop reporting the floor. Never rejects; dedupes
62
+ * with the in-flight background refresh. First turns await this so the
63
+ * backend's minimal_client_version gate never sees a stale floor.
64
+ */
65
+ export function warmCodexClientVersion() {
66
+ if (_cache.value && Date.now() - _cache.fetchedAt < VERSION_TTL_MS) {
67
+ return Promise.resolve(_cache.value);
68
+ }
69
+ if (!_refreshInFlight) {
70
+ _refreshInFlight = _refresh().finally(() => { _refreshInFlight = null; });
71
+ }
72
+ return _refreshInFlight;
73
+ }
74
+
55
75
  function _osType() {
56
76
  // codex os_info reports "Windows"/"Mac OS"/"Linux"; node os.type() gives
57
77
  // Windows_NT/Darwin/Linux. Map to codex's vocabulary.
@@ -897,12 +897,12 @@ export async function sendViaWebSocket({
897
897
 
898
898
  // Warmup writes the same prefix with generate:false, but the first
899
899
  // real response must still be a FULL generating frame. Reusing the
900
- // warmup response_id here causes _buildResponseCreateFrame() to drop
901
- // `instructions` (previous_response_id frames cannot carry them) and
902
- // can also reduce the frame input to [] when the warmup input matches.
903
- // That made first-turn lead/system rules effectively disappear
904
- // ("who are you?" answered as a generic OpenAI assistant). Keep the
905
- // warmup state for cache/trace, but compute the main frame as cold.
900
+ // warmup response_id here would make _computeDelta reduce the frame
901
+ // input to [] when the warmup input matches, and a generate:false
902
+ // warmup is not a chainable response to continue from so the first
903
+ // real turn would generate from an empty frame. Keep the warmup state
904
+ // for cache/trace, but compute the main frame as cold (full input +
905
+ // instructions).
906
906
  const deltaEntry = warmupResult
907
907
  ? {
908
908
  ...entry,
@@ -59,6 +59,7 @@ import {
59
59
  _shouldUseOpenAIHttpFallback,
60
60
  } from './openai-oauth-http-sse.mjs';
61
61
  import { createOpenAIOAuthLogin } from './openai-oauth-login.mjs';
62
+ import { warmCodexClientVersion } from './codex-client-meta.mjs';
62
63
  import {
63
64
  _displayCodexModel,
64
65
  _codexFamily,
@@ -77,42 +78,13 @@ const CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
77
78
  export const CODEX_OAUTH_ORIGINATOR = 'codex_cli_rs';
78
79
  const TOKEN_URL = 'https://auth.openai.com/oauth/token';
79
80
  export const CODEX_RESPONSES_URL = 'https://chatgpt.com/backend-api/codex/responses';
80
- // Version string baked into the models endpoint query the OAuth backend
81
- // rejects the request without it, and gates new model exposures (e.g.
82
- // gpt-5.5 only on >= 0.130.0) on this client_version header; older versions
83
- // trigger a visibility-filtered catalog (e.g. only rollout models). Resolved
84
- // dynamically from npm so newly-shipped models surface within a day instead
85
- // of waiting on a hardcoded bump here. Cached 24h in-process; npm failure
86
- // falls back to the floor below.
87
- // Offline fallback only — _resolveCodexClientVersion() fetches the live
88
- // @openai/codex latest from npm first. Bumped to the current release
89
- // (0.142.5, verified 2026-07-03) so the offline path stays close to what the
90
- // backend expects for client-version gating.
91
- const CODEX_CLIENT_VERSION_FLOOR = '0.142.5';
92
- const CODEX_VERSION_CACHE_TTL_MS = 24 * 60 * 60_000;
93
- let _codexVersionCache = { value: null, fetchedAt: 0 };
94
-
95
- async function _resolveCodexClientVersion() {
96
- const now = Date.now();
97
- if (_codexVersionCache.value && now - _codexVersionCache.fetchedAt < CODEX_VERSION_CACHE_TTL_MS) {
98
- return _codexVersionCache.value;
99
- }
100
- try {
101
- const res = await fetch('https://registry.npmjs.org/@openai/codex/latest', {
102
- signal: AbortSignal.timeout(5_000),
103
- });
104
- if (res.ok) {
105
- const j = await res.json();
106
- const v = String(j?.version || '').trim();
107
- if (/^\d+\.\d+\.\d+/.test(v)) {
108
- _codexVersionCache = { value: v, fetchedAt: now };
109
- return v;
110
- }
111
- }
112
- } catch { /* network down / npm rejects — use floor */ }
113
- _codexVersionCache = { value: CODEX_CLIENT_VERSION_FLOOR, fetchedAt: now };
114
- return CODEX_CLIENT_VERSION_FLOOR;
115
- }
81
+ // Client version for the models endpoint query and the `version`/User-Agent
82
+ // request headers — the OAuth backend rejects requests without it, gates new
83
+ // model exposures on it (gpt-5.6-* require >= 0.144.0), and rejects turns on
84
+ // gated models when the reported version is below the model's
85
+ // minimal_client_version. Resolution is unified in codex-client-meta.mjs
86
+ // (live npm @openai/codex latest, 24h in-process cache, offline floor) so the
87
+ // catalog query and the transport headers can never disagree.
116
88
  const CODEX_MODEL_CACHE_TTL_MS = 24 * 60 * 60_000;
117
89
  const CODEX_MODEL_CACHE_SCHEMA_VERSION = 3;
118
90
  const TOKEN_REFRESH_SKEW_MS = 5 * 60_000;
@@ -784,7 +756,14 @@ export class OpenAIOAuthProvider {
784
756
  ? Promise.resolve(opts._prebuiltBody)
785
757
  : Promise.resolve().then(() => buildRequestBody(messages, useModel, tools, bodyOpts));
786
758
  const _authP = this.ensureAuth();
759
+ // Cold-start guard: the WS/SSE transports read the client version via
760
+ // the SYNC accessor for the `version` header + User-Agent. Await the
761
+ // shared resolver (parallel with auth; never rejects; no-op once
762
+ // cached) so the first turn after boot doesn't report the offline
763
+ // floor and trip the backend's minimal_client_version gate.
764
+ const _verP = warmCodexClientVersion();
787
765
  let auth = await _authP;
766
+ await _verP;
788
767
  const body = await _bodyP;
789
768
  // poolKey != cacheKey by design (see openai-oauth-ws.mjs header note).
790
769
  // poolKey is per-session so parallel reviewer/worker callers each get
@@ -1038,7 +1017,7 @@ export class OpenAIOAuthProvider {
1038
1017
  }
1039
1018
  try {
1040
1019
  const auth = await this.ensureAuth();
1041
- const clientVersion = await _resolveCodexClientVersion();
1020
+ const clientVersion = await warmCodexClientVersion();
1042
1021
  const url = `https://chatgpt.com/backend-api/codex/models?client_version=${clientVersion}`;
1043
1022
  const res = await fetch(url, {
1044
1023
  signal: AbortSignal.timeout(10_000),
@@ -1076,7 +1055,7 @@ export class OpenAIOAuthProvider {
1076
1055
  _codexRefreshInFlight = (async () => {
1077
1056
  try {
1078
1057
  const auth = await this.ensureAuth();
1079
- const clientVersion = await _resolveCodexClientVersion();
1058
+ const clientVersion = await warmCodexClientVersion();
1080
1059
  const url = `https://chatgpt.com/backend-api/codex/models?client_version=${clientVersion}`;
1081
1060
  const res = await fetch(url, {
1082
1061
  signal: AbortSignal.timeout(10_000),
@@ -195,9 +195,11 @@ const TRANSPORT_ONLY_FRAME_FIELDS = new Set(['stream', 'background']);
195
195
  // identical byte-for-byte: `type` always leads, then the body's codex
196
196
  // struct-order keys follow verbatim. A delta send passes previousResponseId
197
197
  // (inserted immediately before `input`, matching codex's refs position) and
198
- // inputOverride (the stripped tail); instructions are dropped whenever
199
- // previous_response_id is present because the server resolves them from the
200
- // chained response and rejects sending both fields together.
198
+ // inputOverride (the stripped tail). `instructions` MUST still be resent on
199
+ // previous_response_id frames: per the OpenAI Responses API, the previous
200
+ // response's top-level instructions are NOT carried over to the chained
201
+ // response, so dropping them here strips the system/lead prompt from every
202
+ // continuation turn. Only an empty instructions string is omitted.
201
203
  // Full/warmup frames pass the body unchanged and keep every key in place.
202
204
  // omitTransportFields is used by wire-parity/prewarm helpers to drop stream/background.
203
205
  export function _buildResponseCreateFrame(body, { previousResponseId = null, inputOverride, omitTransportFields = false } = {}) {
@@ -215,7 +217,6 @@ export function _buildResponseCreateFrame(body, { previousResponseId = null, inp
215
217
  for (const key of Object.keys(src)) {
216
218
  if (omitTransportFields && TRANSPORT_ONLY_FRAME_FIELDS.has(key)) continue;
217
219
  if (key === 'instructions') {
218
- if (previousResponseId != null) continue;
219
220
  const instr = src.instructions;
220
221
  if (typeof instr === 'string' && instr.length) frame.instructions = instr;
221
222
  continue;
@@ -15,6 +15,10 @@ import { codexOriginator, codexUserAgent, codexVersionHeader } from './codex-cli
15
15
  import {
16
16
  PROVIDER_WS_ACQUIRE_TIMEOUT_MS,
17
17
  PROVIDER_WS_HANDSHAKE_TIMEOUT_MS,
18
+ PROVIDER_WS_PING_ENABLED,
19
+ PROVIDER_WS_PING_INTERVAL_MS,
20
+ PROVIDER_WS_PONG_TIMEOUT_MS,
21
+ PROVIDER_WS_LIVENESS_STALE_MS,
18
22
  resolveTimeoutMs,
19
23
  } from '../stall-policy.mjs';
20
24
 
@@ -36,6 +40,10 @@ export const WS_IDLE_MS = resolveTimeoutMs(
36
40
  );
37
41
  const WS_HANDSHAKE_TIMEOUT_MS = PROVIDER_WS_HANDSHAKE_TIMEOUT_MS;
38
42
  const WS_ACQUIRE_TIMEOUT_MS = PROVIDER_WS_ACQUIRE_TIMEOUT_MS;
43
+ const WS_PING_INTERVAL_MS = PROVIDER_WS_PING_INTERVAL_MS;
44
+ const WS_PONG_TIMEOUT_MS = PROVIDER_WS_PONG_TIMEOUT_MS;
45
+ const WS_LIVENESS_STALE_MS = PROVIDER_WS_LIVENESS_STALE_MS;
46
+ const WS_PING_ENABLED = PROVIDER_WS_PING_ENABLED;
39
47
 
40
48
  // WS socket pool buckets are keyed by `poolKey` (the per-call sessionId)
41
49
  // to isolate parallel agent invocations — each gets its own socket so
@@ -263,6 +271,10 @@ function _getPoolArr(poolKey) {
263
271
  }
264
272
 
265
273
  function _removeFromPool(poolKey, entry) {
274
+ // Always tear down per-entry timers so evicting a socket never leaks an
275
+ // idle-close or liveness-ping interval.
276
+ _clearIdle(entry);
277
+ _clearLiveness(entry);
266
278
  if (!poolKey) return;
267
279
  const arr = _wsPool.get(poolKey);
268
280
  if (!arr) return;
@@ -293,6 +305,70 @@ function _isOpen(entry) {
293
305
  return entry?.socket?.readyState === WebSocket.OPEN;
294
306
  }
295
307
 
308
+ function _clearLiveness(entry) {
309
+ if (entry?.pingTimer) {
310
+ clearInterval(entry.pingTimer);
311
+ entry.pingTimer = null;
312
+ }
313
+ }
314
+
315
+ // Force a dead/half-open socket out of the pool. close() alone can hang on a
316
+ // wedged socket, so follow with terminate() to guarantee FD release.
317
+ function _evictDead(poolKey, entry) {
318
+ try { entry.socket.close(1000, 'ws_liveness_dead'); } catch {}
319
+ try { entry.socket.terminate?.(); } catch {}
320
+ _removeFromPool(poolKey, entry);
321
+ }
322
+
323
+ // Send one ws-level ping and resolve true iff a pong lands within timeoutMs.
324
+ // Never rejects. On any success it refreshes lastAliveAt so the caller/loop
325
+ // treats the socket as fresh.
326
+ function _pingProbe(entry, timeoutMs) {
327
+ return new Promise((resolve) => {
328
+ const socket = entry?.socket;
329
+ if (!socket || socket.readyState !== WebSocket.OPEN) { resolve(false); return; }
330
+ let done = false;
331
+ const finish = (alive) => {
332
+ if (done) return;
333
+ done = true;
334
+ clearTimeout(timer);
335
+ try { socket.removeListener('pong', onPong); } catch {}
336
+ resolve(alive);
337
+ };
338
+ const onPong = () => { entry.lastAliveAt = Date.now(); finish(true); };
339
+ const timer = setTimeout(() => finish(false), timeoutMs);
340
+ try { timer.unref?.(); } catch {}
341
+ try {
342
+ socket.on('pong', onPong);
343
+ socket.ping();
344
+ } catch {
345
+ finish(false);
346
+ }
347
+ });
348
+ }
349
+
350
+ // While an entry sits idle in the pool, ping it every WS_PING_INTERVAL_MS.
351
+ // A missed pong (or a socket that is no longer OPEN) evicts the entry so it can
352
+ // never be handed out dead. Busy entries are skipped — an in-flight turn has
353
+ // its own inter-chunk/semantic-idle watchdogs.
354
+ function _armLiveness(poolKey, entry) {
355
+ _clearLiveness(entry);
356
+ entry.pingTimer = setInterval(async () => {
357
+ if (entry.busy || entry.closing || entry.probing) return;
358
+ if (!_isOpen(entry)) { _evictDead(poolKey, entry); return; }
359
+ // Recent activity ⇒ assume live, skip the probe this tick.
360
+ if (Date.now() - (entry.lastAliveAt || 0) < WS_LIVENESS_STALE_MS) return;
361
+ entry.probing = true;
362
+ try {
363
+ const alive = await _pingProbe(entry, WS_PONG_TIMEOUT_MS);
364
+ if (!alive && !entry.busy) _evictDead(poolKey, entry);
365
+ } finally {
366
+ entry.probing = false;
367
+ }
368
+ }, WS_PING_INTERVAL_MS);
369
+ try { entry.pingTimer.unref?.(); } catch {}
370
+ }
371
+
296
372
  // Awaited frame send. Asserts the socket is OPEN and resolves only after
297
373
  // the underlying transport reports the buffered write succeeded (or fails)
298
374
  // via the WebSocket send callback. Raw `socket.send(JSON.stringify(...))`
@@ -596,15 +672,36 @@ export async function acquireWebSocket({ auth, poolKey, cacheKey, codexHeaders,
596
672
  for (let i = arr.length - 1; i >= 0; i--) {
597
673
  if (!_isOpen(arr[i]) || arr[i].closing) {
598
674
  _clearIdle(arr[i]);
675
+ _clearLiveness(arr[i]);
599
676
  arr.splice(i, 1);
600
677
  }
601
678
  }
602
679
  if (arr.length === 0) _wsPool.delete(poolKey);
603
- // Reuse any idle open entry (cache-warm path).
604
- const idle = arr.find(e => !e.busy);
605
- if (idle) {
680
+ // Reuse an idle open entry (cache-warm path). An entry with no observed
681
+ // activity within the freshness window is ping-probed under a short
682
+ // bound before hand-out; a dead one is evicted and the scan retries the
683
+ // next idle entry so a busy caller is never handed a wedged socket.
684
+ let idle;
685
+ while ((idle = arr.find(e => !e.busy))) {
606
686
  _clearIdle(idle);
687
+ _clearLiveness(idle);
688
+ // Reserve the entry BEFORE awaiting the probe: _pingProbe yields the
689
+ // event loop, so without this a second concurrent acquire could scan
690
+ // the same still-idle entry and both would take it. Marking busy up
691
+ // front makes the find() above skip it; on probe failure it is
692
+ // evicted (removed from arr) so the loop continues cleanly.
607
693
  idle.busy = true;
694
+ if (WS_PING_ENABLED && Date.now() - (idle.lastAliveAt || 0) >= WS_LIVENESS_STALE_MS) {
695
+ const alive = await _pingProbe(idle, WS_PONG_TIMEOUT_MS);
696
+ if (!alive) {
697
+ if (process.env.MIXDOG_DEBUG_AGENT) {
698
+ process.stderr.write(`[agent-trace] acquire-evict-dead poolKey=${poolKey} reason=missed_pong elapsed=${Date.now() - _acqStart}ms\n`);
699
+ }
700
+ _evictDead(poolKey, idle);
701
+ continue;
702
+ }
703
+ }
704
+ idle.lastAliveAt = Date.now();
608
705
  // Defensive: pre-existing pooled entries created before the
609
706
  // prefix-hash field was introduced may not have it set. Normalize
610
707
  // to null so the first delta check reads a deterministic value
@@ -646,6 +743,11 @@ export async function acquireWebSocket({ auth, poolKey, cacheKey, codexHeaders,
646
743
  ephemeral: true,
647
744
  sessionToken: ephSessionToken,
648
745
  };
746
+ entry.lastAliveAt = Date.now();
747
+ entry.pingTimer = null;
748
+ entry.probing = false;
749
+ socket.on('pong', () => { entry.lastAliveAt = Date.now(); });
750
+ socket.on('message', () => { entry.lastAliveAt = Date.now(); });
649
751
  socket.on('close', () => { entry.closing = true; });
650
752
  return { entry, reused: false };
651
753
  }
@@ -674,6 +776,11 @@ export async function acquireWebSocket({ auth, poolKey, cacheKey, codexHeaders,
674
776
  ephemeral: false,
675
777
  sessionToken,
676
778
  };
779
+ entry.lastAliveAt = Date.now();
780
+ entry.pingTimer = null;
781
+ entry.probing = false;
782
+ socket.on('pong', () => { entry.lastAliveAt = Date.now(); });
783
+ socket.on('message', () => { entry.lastAliveAt = Date.now(); });
677
784
  if (poolKey && !forceFresh) _getPoolArr(poolKey).push(entry);
678
785
  socket.on('close', () => {
679
786
  entry.closing = true;
@@ -690,7 +797,12 @@ export function releaseWebSocket({ entry, poolKey, keep }) {
690
797
  _removeFromPool(poolKey, entry);
691
798
  return;
692
799
  }
800
+ // Mark activity at release, then arm both the idle-close timer and the
801
+ // periodic liveness ping so a socket that dies while pooled is evicted
802
+ // before the next acquire can hand it out.
803
+ entry.lastAliveAt = Date.now();
693
804
  _scheduleIdleClose(poolKey, entry);
805
+ if (WS_PING_ENABLED) _armLiveness(poolKey, entry);
694
806
  }
695
807
 
696
808
  // Drain-complete fence — set true once _closeAllPooledSockets runs so any
@@ -706,6 +818,10 @@ export function _closeAllPooledSockets(reason = 'shutdown') {
706
818
  _drainComplete = true;
707
819
  for (const arr of _wsPool.values()) {
708
820
  for (const entry of arr) {
821
+ // Tear down per-entry timers before dropping the map, otherwise the
822
+ // idle-close and liveness-ping intervals outlive the drained pool.
823
+ _clearIdle(entry);
824
+ _clearLiveness(entry);
709
825
  try { entry.socket.close(1000, reason); } catch {}
710
826
  }
711
827
  }
@@ -305,11 +305,18 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
305
305
  // cut off early. Other runaway protection is behavior-based (steering
306
306
  // ladder hints, REPEAT_FAIL_LIMIT), never a lower iteration count.
307
307
  let _iterWarnStage = 0;
308
- const _iterWarnAt = [
309
- Math.floor(maxLoopIterations * 0.5),
310
- Math.floor(maxLoopIterations * 0.75),
311
- Math.floor(maxLoopIterations * 0.9),
312
- ];
308
+ // Tiny-cap loops (e.g. explorer cap=3) can't afford staged 50/75/90%
309
+ // steers — the 50% stage lands on iteration 1 in every session, spamming
310
+ // the normal batch→answer path. For caps < 10 emit ONE wrap-up warning at
311
+ // the penultimate iteration instead; caps >= 10 keep staged behavior.
312
+ const _singleWarn = maxLoopIterations < 10;
313
+ const _iterWarnAt = _singleWarn
314
+ ? [Math.max(1, maxLoopIterations - 1)]
315
+ : [
316
+ Math.floor(maxLoopIterations * 0.5),
317
+ Math.floor(maxLoopIterations * 0.75),
318
+ Math.floor(maxLoopIterations * 0.9),
319
+ ];
313
320
  while (true) {
314
321
  throwIfAborted();
315
322
  if (iterations >= maxLoopIterations) {
@@ -341,9 +348,11 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
341
348
  if (_iterWarnStage < _iterWarnAt.length && iterations >= _iterWarnAt[_iterWarnStage]) {
342
349
  _iterWarnStage += 1;
343
350
  const warnAt = _iterWarnAt[_iterWarnStage - 1];
344
- const stageMsg = _iterWarnStage === 1
345
- ? `Iteration budget notice: ${warnAt} of ${maxLoopIterations} iterations used. Converge on a conclusion: prefer finishing the current objective over opening new exploration.`
346
- : `Iteration budget warning (stage ${_iterWarnStage}): ${warnAt} of ${maxLoopIterations} iterations used — the loop hard-stops at ${maxLoopIterations}. Wrap up now: summarize progress, state what remains, and finish with your best current result.`;
351
+ const stageMsg = _singleWarn
352
+ ? `Iteration budget nearly spent: ${warnAt} of ${maxLoopIterations} iterations used answer NOW with the best anchors you already hold.`
353
+ : _iterWarnStage === 1
354
+ ? `Iteration budget notice: ${warnAt} of ${maxLoopIterations} iterations used. Converge on a conclusion: prefer finishing the current objective over opening new exploration.`
355
+ : `Iteration budget warning (stage ${_iterWarnStage}): ${warnAt} of ${maxLoopIterations} iterations used — the loop hard-stops at ${maxLoopIterations}. Wrap up now: summarize progress, state what remains, and finish with your best current result.`;
347
356
  messages.push({ role: 'user', content: `<system-reminder>\n${stageMsg}\n</system-reminder>`, meta: 'hook' });
348
357
  process.stderr.write(`[loop] iteration warning stage ${_iterWarnStage} at ${iterations} (sess=${sessionId || 'unknown'}); continuing with steer.\n`);
349
358
  try {