mixdog 0.9.93 → 0.9.95

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 (186) hide show
  1. package/NOTICE.md +72 -0
  2. package/package.json +16 -6
  3. package/scripts/lib/isolated-root-cleanup.mjs +19 -0
  4. package/scripts/run-suite.mjs +100 -0
  5. package/src/lib/rules-builder.cjs +4 -3
  6. package/src/output-styles/detailed.md +13 -15
  7. package/src/output-styles/extreme-minimal.md +7 -11
  8. package/src/output-styles/minimal.md +4 -7
  9. package/src/output-styles/simple.md +11 -13
  10. package/src/rules/agent/30-explorer.md +22 -16
  11. package/src/rules/lead/01-general.md +1 -2
  12. package/src/rules/lead/lead-brief.md +4 -5
  13. package/src/rules/lead/lead-tool.md +3 -2
  14. package/src/rules/shared/01-tool.md +28 -29
  15. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +2 -2
  16. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +23 -7
  17. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +4 -0
  18. package/src/runtime/agent/orchestrator/agent-trace.mjs +29 -0
  19. package/src/runtime/agent/orchestrator/context/collect.mjs +6 -2
  20. package/src/runtime/agent/orchestrator/mcp/client.mjs +3 -3
  21. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +97 -7
  22. package/src/runtime/agent/orchestrator/providers/anthropic-model-resolve.mjs +11 -4
  23. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +2 -3
  24. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +1 -1
  25. package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +4 -6
  26. package/src/runtime/agent/orchestrator/providers/lib/stream-outcome.mjs +1 -1
  27. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +75 -15
  28. package/src/runtime/agent/orchestrator/providers/openai-codex-metadata.mjs +8 -8
  29. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +7 -8
  30. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +2 -2
  31. package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +14 -16
  32. package/src/runtime/agent/orchestrator/providers/openai-ws-headers.mjs +5 -4
  33. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +10 -10
  34. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +2 -3
  35. package/src/runtime/agent/orchestrator/providers/registry.mjs +51 -4
  36. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +15 -15
  37. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +12 -32
  38. package/src/runtime/agent/orchestrator/session/cache/read-cache.mjs +7 -0
  39. package/src/runtime/agent/orchestrator/session/cache/scoped-cache.mjs +42 -2
  40. package/src/runtime/agent/orchestrator/session/context-compaction-policy.mjs +10 -3
  41. package/src/runtime/agent/orchestrator/session/context-utils.mjs +10 -11
  42. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +1 -0
  43. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +3 -3
  44. package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +0 -14
  45. package/src/runtime/agent/orchestrator/session/loop/stop-hooks.mjs +9 -8
  46. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +7 -4
  47. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +1 -1
  48. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +1 -1
  49. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +40 -13
  50. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +3 -0
  51. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +8 -1
  52. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +5 -9
  53. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +3 -3
  54. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +103 -92
  55. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +98 -3
  56. package/src/runtime/agent/orchestrator/stall-policy.mjs +2 -3
  57. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +3 -3
  58. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +25 -3
  59. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +10 -2
  60. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +5 -5
  61. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +12 -3
  62. package/src/runtime/agent/orchestrator/tools/builtin/grep-formatting.mjs +22 -0
  63. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-context-expander.mjs +491 -0
  64. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-output.mjs +91 -13
  65. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +90 -27
  66. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +20 -1
  67. package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +1 -1
  68. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +4 -4
  69. package/src/runtime/agent/orchestrator/tools/builtin/read-image-resize.mjs +2 -2
  70. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +19 -9
  71. package/src/runtime/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +2 -1
  72. package/src/runtime/agent/orchestrator/tools/builtin/read-special-files.mjs +3 -3
  73. package/src/runtime/agent/orchestrator/tools/builtin/read-streaming.mjs +7 -2
  74. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +32 -4
  75. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +1 -1
  76. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +16 -1
  77. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +546 -23
  78. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +8 -5
  79. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +12 -0
  80. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-spawn.mjs +10 -3
  81. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +35 -5
  82. package/src/runtime/agent/orchestrator/tools/builtin/shell-output.mjs +3 -3
  83. package/src/runtime/agent/orchestrator/tools/builtin/snapshot-store.mjs +98 -0
  84. package/src/runtime/agent/orchestrator/tools/builtin/tool-output-limit.mjs +48 -0
  85. package/src/runtime/agent/orchestrator/tools/builtin.mjs +71 -1
  86. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +7 -3
  87. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +104 -14
  88. package/src/runtime/agent/orchestrator/tools/code-graph/project-root.mjs +47 -2
  89. package/src/runtime/agent/orchestrator/tools/code-graph/search-references.mjs +6 -17
  90. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +2 -4
  91. package/src/runtime/agent/orchestrator/tools/code-graph/trusted-roots.mjs +3 -1
  92. package/src/runtime/agent/orchestrator/tools/env-scrub.mjs +9 -2
  93. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +5 -5
  94. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +1 -1
  95. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +57 -2
  96. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +151 -18
  97. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +5 -1
  98. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +109 -13
  99. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +10 -10
  100. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +4 -5
  101. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +4 -0
  102. package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +1 -1
  103. package/src/runtime/channels/backends/discord.mjs +5 -14
  104. package/src/runtime/channels/backends/telegram.mjs +0 -5
  105. package/src/runtime/channels/lib/inbound-handler.mjs +0 -1
  106. package/src/runtime/channels/lib/output-forwarder.mjs +24 -5
  107. package/src/runtime/channels/lib/scheduler.mjs +1 -1
  108. package/src/runtime/channels/lib/worker-main.mjs +1 -1
  109. package/src/runtime/media/renditions.mjs +21 -2
  110. package/src/runtime/memory/lib/tool-call-handler.mjs +16 -1
  111. package/src/runtime/memory/tool-defs.mjs +6 -6
  112. package/src/runtime/shared/atomic-file.mjs +53 -0
  113. package/src/runtime/shared/background-tasks.mjs +16 -6
  114. package/src/runtime/shared/child-spawn-gate.mjs +50 -26
  115. package/src/runtime/shared/resource-admission.mjs +40 -1
  116. package/src/runtime/shared/task-notification-envelope.mjs +11 -2
  117. package/src/runtime/shared/tool-card-model.mjs +6 -2
  118. package/src/runtime/shared/tool-status.mjs +10 -1
  119. package/src/runtime/shared/tool-surface.mjs +5 -0
  120. package/src/runtime/shared/turn-snapshot.mjs +385 -21
  121. package/src/runtime/shared/turn-worktree-snapshot.mjs +540 -0
  122. package/src/session-runtime/context-status.mjs +9 -3
  123. package/src/session-runtime/lifecycle-api.mjs +35 -5
  124. package/src/session-runtime/mcp-glue.mjs +11 -6
  125. package/src/session-runtime/provider-models.mjs +94 -43
  126. package/src/session-runtime/provider-usage.mjs +26 -2
  127. package/src/session-runtime/runtime-core.mjs +46 -8
  128. package/src/session-runtime/runtime-tunables.mjs +4 -0
  129. package/src/session-runtime/self-update.mjs +33 -4
  130. package/src/session-runtime/session-lifecycle.mjs +2 -0
  131. package/src/session-runtime/session-text.mjs +2 -1
  132. package/src/session-runtime/session-turn-api.mjs +106 -22
  133. package/src/session-runtime/workflow.mjs +11 -4
  134. package/src/standalone/agent-tool.mjs +4 -4
  135. package/src/standalone/backend-daemon.mjs +570 -0
  136. package/src/standalone/channel-daemon-transport.mjs +141 -1
  137. package/src/standalone/channel-worker.mjs +3 -2
  138. package/src/standalone/engine-daemon-client.mjs +894 -0
  139. package/src/standalone/engine-daemon-local-bridge.mjs +20 -0
  140. package/src/standalone/engine-daemon-protocol.mjs +33 -0
  141. package/src/standalone/engine-daemon-service.mjs +864 -0
  142. package/src/standalone/engine-daemon-transport.mjs +603 -0
  143. package/src/standalone/explore-tool.mjs +1 -1
  144. package/src/tui/App.jsx +62 -47
  145. package/src/tui/app/app-format.mjs +4 -2
  146. package/src/tui/app/app-view.jsx +5 -0
  147. package/src/tui/app/channel-pickers.mjs +7 -6
  148. package/src/tui/app/core-memory-picker.mjs +4 -4
  149. package/src/tui/app/extension-pickers.mjs +20 -18
  150. package/src/tui/app/maintenance-pickers.mjs +27 -27
  151. package/src/tui/app/onboarding-steps.mjs +23 -18
  152. package/src/tui/app/prompt-submit.mjs +13 -2
  153. package/src/tui/app/route-pickers.mjs +13 -8
  154. package/src/tui/app/settings-picker.mjs +55 -58
  155. package/src/tui/app/slash-dispatch.mjs +32 -28
  156. package/src/tui/app/transcript-window.mjs +19 -0
  157. package/src/tui/app/usage-context-panels.mjs +22 -7
  158. package/src/tui/app/use-mouse-input.mjs +25 -3
  159. package/src/tui/app/use-prompt-queue-history.mjs +31 -16
  160. package/src/tui/app/use-transcript-scroll.mjs +30 -8
  161. package/src/tui/app/use-transcript-window.mjs +22 -1
  162. package/src/tui/app/use-welcome-prompt-hint.mjs +2 -2
  163. package/src/tui/components/PromptInput.jsx +10 -0
  164. package/src/tui/components/Spinner.jsx +89 -86
  165. package/src/tui/components/TextEntryPanel.jsx +14 -0
  166. package/src/tui/components/ToolExecution.jsx +2 -2
  167. package/src/tui/dist/index.mjs +1537 -9718
  168. package/src/tui/engine/agent-job-feed.mjs +2 -2
  169. package/src/tui/engine/live-share.mjs +97 -4
  170. package/src/tui/engine/session-api-ext.mjs +23 -1
  171. package/src/tui/engine/session-api.mjs +88 -41
  172. package/src/tui/engine/session-flow.mjs +43 -4
  173. package/src/tui/engine/tool-card-results.mjs +6 -0
  174. package/src/tui/engine/turn.mjs +84 -4
  175. package/src/tui/engine-local-session.mjs +1108 -0
  176. package/src/tui/engine.mjs +16 -1057
  177. package/src/tui/index.jsx +47 -2
  178. package/src/tui/markdown/stream-fence.mjs +1 -1
  179. package/src/tui/spinner-meta.mjs +80 -0
  180. package/src/tui/spinner-verbs.mjs +35 -0
  181. package/src/ui/statusline-segments.mjs +43 -10
  182. package/src/ui/statusline.mjs +10 -1
  183. package/scripts/tmp-cdp-errors.mjs +0 -41
  184. package/scripts/tmp-cdp-inspect.mjs +0 -41
  185. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +0 -176
  186. package/src/standalone/channel-daemon.mjs +0 -226
@@ -126,8 +126,8 @@ function _isMaxOutputIncompleteReason(reason) {
126
126
  return /^(?:max_output_tokens|max_tokens|length|output_token_limit)$/i.test(String(reason || '').trim());
127
127
  }
128
128
 
129
- // Wire-level `end_turn` on a terminal Responses frame (codex-rs
130
- // codex-api/src/sse/responses.rs ResponseCompleted.end_turn: Option<bool>).
129
+ // Wire-level `end_turn` on a terminal Responses frame: an optional boolean on
130
+ // the completed response.
131
131
  // Optional by contract: only a real boolean normalizes; a missing/non-boolean
132
132
  // field stays undefined so absence is never collapsed into false.
133
133
  export function _endTurnFromEvent(event) {
@@ -178,9 +178,9 @@ function _buildOpenAIHttpFallbackHeaders({ auth, cacheKey }) {
178
178
  };
179
179
  if (cacheKey) {
180
180
  const sid = String(cacheKey);
181
- // Codex-native anchors (see openai-ws-pool _buildHandshakeHeaders):
182
- // `session-id`/`thread-id` (hyphen) match codex-rs headers.rs; legacy
183
- // underscore `session_id` kept for backward compat.
181
+ // Backend-native anchors (see openai-ws-pool _buildHandshakeHeaders):
182
+ // the hyphenated `session-id`/`thread-id` pair; legacy underscore
183
+ // `session_id` kept for backward compat.
184
184
  headers.session_id = sid;
185
185
  headers['session-id'] = sid;
186
186
  headers['thread-id'] = sid;
@@ -244,9 +244,8 @@ export async function sendViaHttpSse({
244
244
  const responsesUrl = auth?.type === 'openai-direct'
245
245
  ? OPENAI_DIRECT_RESPONSES_URL
246
246
  : CODEX_RESPONSES_URL;
247
- // Request-body zstd (codex parity: core client.rs
248
- // responses_request_compression enables zstd for the codex backend on the
249
- // OpenAI provider — the server decompresses Content-Encoding: zstd).
247
+ // Request-body zstd: compression is enabled for the codex backend on the
248
+ // OpenAI provider — the server decompresses Content-Encoding: zstd.
250
249
  // openai-direct is excluded: only the codex backend is verified. Env
251
250
  // kill-switch plus a process-wide latch flipped on the first 400 seen on
252
251
  // a compressed request, which then replays that attempt uncompressed.
@@ -88,7 +88,7 @@ globalThis.__mixdogOpenaiWsRuntimeLoaded = true;
88
88
  // by connect/handshake and pre-output stream failures.
89
89
  const MIDSTREAM_WS_TRANSIENT_RETRY_LIMIT = MIDSTREAM_RETRY_POLICY.ws.transientCloseRetries;
90
90
  const MIDSTREAM_DEFAULT_RETRY_LIMIT = MIDSTREAM_RETRY_POLICY.ws.defaultRetries;
91
- // Codex core/src/util.rs uses a 200ms base, factor 2, and symmetric ±10%
91
+ // The reference client uses a 200ms base, factor 2, and symmetric ±10%
92
92
  // jitter for each of its five stream retries.
93
93
  const MIDSTREAM_BACKOFF_MS = Object.freeze([200, 400, 800, 1600, 3200]);
94
94
  const CODEX_RETRY_JITTER_RATIO = 0.1;
@@ -773,7 +773,7 @@ export async function sendViaWebSocket({
773
773
  let result;
774
774
  const streamTimeouts = null;
775
775
  try {
776
- // codex prewarm gate (client.rs:1686-1688): only when the session
776
+ // Prewarm gate: only when the session
777
777
  // has no prior request state. A reused pooled socket with a live
778
778
  // chain must go straight to the real request.
779
779
  if (warmupBody && typeof warmupBody === 'object' && !completedWarmup
@@ -203,7 +203,7 @@ export function toOpenAIResponsesTool(t) {
203
203
 
204
204
  export const _convertMessagesToResponsesInputForTest = convertMessagesToResponsesInput;
205
205
 
206
- // codex build_reasoning() (core/src/client.rs:785-805) only attaches the
206
+ // The reference client only attaches the
207
207
  // reasoning object when model_info.supports_reasoning_summaries; models
208
208
  // without summary support get NO reasoning field at all. Mirror that via the
209
209
  // cached codex catalog; unknown models default to true (gpt-5 family all
@@ -218,7 +218,7 @@ function _codexModelSupportsReasoningSummaries(id) {
218
218
  return true;
219
219
  }
220
220
 
221
- // codex reasoning_effort_for_request (core/src/client.rs): `ultra` collapses to
221
+ // Effort normalization: `ultra` collapses to
222
222
  // `max` on the wire — the openai-oauth backend does not accept `ultra`. Every
223
223
  // other effort passes through unchanged; empty/unknown falls back to medium.
224
224
  export function _normalizeReasoningEffort(effort) {
@@ -251,12 +251,12 @@ export function buildRequestBody(messages, model, tools, sendOpts) {
251
251
  const value = String(item || '').trim();
252
252
  if (value && !include.includes(value)) include.push(value);
253
253
  }
254
- // Field order MIRRORS codex-rs ResponsesApiRequest (common.rs struct order):
254
+ // Field order MIRRORS the reference request struct:
255
255
  // model, instructions, input, tools, tool_choice, parallel_tool_calls,
256
256
  // reasoning, store, stream, include, service_tier, prompt_cache_key, text.
257
257
  // JSON serialization order is load-bearing for the server prompt cache
258
- // (exact-prefix match): matching codex's byte layout keeps our requests on
259
- // the same cache-routing shape codex warms. tools/service_tier/
258
+ // (exact-prefix match): matching that byte layout keeps our requests on
259
+ // the same cache-routing shape the backend warms. tools/service_tier/
260
260
  // prompt_cache_key are appended below in the same relative order.
261
261
  const body = {
262
262
  model,
@@ -264,17 +264,15 @@ export function buildRequestBody(messages, model, tools, sendOpts) {
264
264
  input,
265
265
  tool_choice: opts.toolChoice || 'auto',
266
266
  parallel_tool_calls: true,
267
- // codex build_reasoning() sends { effort, summary } — summary defaults to
268
- // ReasoningSummary::Auto (protocol config_types.rs), serialized lowercase
269
- // as "auto". Matching this keeps our reasoning object byte-identical to
270
- // codex so the server prompt-cache prefix hash lines up. codex also
271
- // normalizes `ultra` -> `max` on the wire (reasoning_effort_for_request
272
- // in core/src/client.rs); the openai-oauth backend does not accept
273
- // `ultra` as a wire value, so mirror that mapping here.
274
- // WIRE-VERIFIED (codex desktop logs_2.sqlite, 40 response.create
275
- // captures, 2026-07-03): codex sends reasoning as {"effort":"..."}
276
- // with NO summary field on gpt-5.5, regardless of what the repo's
277
- // build_reasoning() suggests. Match the observed bytes.
267
+ // The reference client sends { effort, summary } — summary defaults
268
+ // to "auto" (lowercase on the wire). Matching this keeps our
269
+ // reasoning object byte-identical so the server prompt-cache prefix
270
+ // hash lines up. `ultra` is normalized to `max` on the wire too; the
271
+ // openai-oauth backend does not accept `ultra` as a wire value, so
272
+ // mirror that mapping here.
273
+ // WIRE-VERIFIED (40 response.create captures, 2026-07-03): the wire
274
+ // carries reasoning as {"effort":"..."} with NO summary field on
275
+ // gpt-5.5. Match the observed bytes.
278
276
  reasoning: { effort: _normalizeReasoningEffort(opts.effort) },
279
277
  store: process.env.MIXDOG_OAI_STORE === 'true' ? true : false,
280
278
  stream: true,
@@ -41,8 +41,8 @@ export function _envOn(name) {
41
41
  // (MIXDOG_OAI_CODEX_WIRE_PARITY=1 + ws-delta + underscore session_id) exactly
42
42
  // as-is. These add EXTRA parity dimensions for backend fingerprint probes.
43
43
 
44
- // codex sends dashed RFC-4122 UUIDs as session-id/thread-id (client.rs:1033-
45
- // 1057); we key those dashed handshake headers off the underscore cacheKey by
44
+ // The reference client sends dashed RFC-4122 UUIDs as session-id/thread-id;
45
+ // we key those dashed handshake headers off the underscore cacheKey by
46
46
  // default. Opt in with MIXDOG_OAI_CODEX_WIRE_PARITY_UUID_IDS to reshape ONLY
47
47
  // the dashed pair (session-id/thread-id/x-client-request-id) into codex's UUID
48
48
  // format. The value is derived deterministically from the id so it stays
@@ -108,11 +108,12 @@ export function _codexBetaFeatures() {
108
108
  return out.join(',');
109
109
  }
110
110
 
111
- // --- Opt-in raw WS capture for byte-diff against codex-rs -------------------
111
+ // --- Opt-in raw WS capture for wire byte-diff -------------------------------
112
112
  // Enabled ONLY when MIXDOG_OAI_WS_DUMP_DIR names a directory. Persists the
113
113
  // (redacted) handshake header metadata and the exact serialized
114
114
  // response.create frame bytes so our wire format can be byte-diffed against
115
- // codex. Secrets (Authorization / Cookie / account-id / routing tokens) are
115
+ // the reference client. Secrets (Authorization / Cookie / account-id /
116
+ // routing tokens) are
116
117
  // hashed, never written in clear. When the env is unset both helpers are
117
118
  // no-ops, so there is no default behavior change.
118
119
  const _WS_DUMP_SECRET_RE = /^(authorization|proxy-authorization|cookie|set-cookie|chatgpt-account-id|x-codex-turn-state|session_id|session-id|thread-id|x-codex-parent-thread-id|x-client-request-id|x-session-affinity)$/i;
@@ -110,16 +110,16 @@ function _selectIdleEntry(entries, compatibility) {
110
110
  }
111
111
 
112
112
  // --- Cache-route probe state (2026-07-04 hunt) -----------------------------
113
- // CF cookie stickiness (codex chatgpt_cloudflare_cookies.rs:22-55 persists
113
+ // CF cookie stickiness (the reference client persists
114
114
  // __cf_bm/_cfuvid across HTTP clients; our WS handshakes never echo them, so
115
115
  // Cloudflare may re-shard every fresh socket). Jar is per-process, keyed by
116
116
  // auth account. Env knobs (A/B):
117
117
  // MIXDOG_OAI_CF_COOKIES=1 capture Set-Cookie from the 101 upgrade and
118
118
  // send Cookie on subsequent handshakes
119
119
  // MIXDOG_OAI_SESSION_AFFINITY=1 send x-session-affinity: <cacheKey>
120
- // (opencode request.ts:187, ws-pool.ts:66)
121
- // MIXDOG_OAI_WS_URL_SESSION=0 drop the ?session_id= URL query (codex/pi/
122
- // opencode all use the bare WS URL)
120
+ // (a known cache-affinity hint)
121
+ // MIXDOG_OAI_WS_URL_SESSION=0 drop the ?session_id= URL query (reference
122
+ // clients all use the bare WS URL)
123
123
  function _getPoolArr(poolKey) {
124
124
  if (!poolKey) return null;
125
125
  let arr = _wsPool.get(poolKey);
@@ -340,15 +340,15 @@ function _buildHandshakeHeaders({ auth, sessionToken, turnState, cacheKey: _cach
340
340
  'x-codex-beta-features': _codexBetaFeatures(),
341
341
  };
342
342
  const isOpenAiOauth = auth.type !== 'xai' && auth.type !== 'openai-direct';
343
- // codex-rs sends only the dashed session-id/thread-id pair
344
- // (client.rs:1033-1057), but OUR backend measurements disagree with pure
345
- // codex parity here: 2026-04-19 probes showed the OAuth backend dedupes
343
+ // The reference client sends only the dashed session-id/thread-id pair,
344
+ // but OUR backend measurements disagree with pure
345
+ // wire parity here: 2026-04-19 probes showed the OAuth backend dedupes
346
346
  // its in-memory prefix state by the underscore session_id handshake
347
347
  // header, and the only 0.0%-miss full-frame rounds (R7/R8, R15 regressed
348
348
  // to 13% after this header was dropped) all had it present. Send both.
349
349
  // The underscore session_id is the backend prefix-dedupe key (2026-04-19
350
- // probes; R15 regressed to 13% miss when it was dropped). Codex parity
351
- // (client.rs:1033-1057) sends ONLY the dashed pair, but dropping this
350
+ // probes; R15 regressed to 13% miss when it was dropped). Strict wire
351
+ // parity sends ONLY the dashed pair, but dropping this
352
352
  // header is a KNOWN cache-unsafe change — so the general parity flag no
353
353
  // longer silently drops it. Keep it unless an operator EXPLICITLY opts
354
354
  // into the codex-exact dashed-only wire via
@@ -432,7 +432,7 @@ function _openSocket({ auth, sessionToken, turnState, externalSignal, cacheKey,
432
432
  if (process.env.MIXDOG_DEBUG_AGENT) {
433
433
  process.stderr.write(`[agent-trace] ws-open-start url=${baseUrl} tokenHash=${createHash('sha256').update(String(sessionToken)).digest('hex').slice(0, 8)} ts=${_wsOpenStart}\n`);
434
434
  }
435
- // Bare WS URL by default (codex/pi/opencode parity). Interleaved A/B
435
+ // Bare WS URL by default (reference-client parity). Interleaved A/B
436
436
  // (2026-07-04, ivA/ivB, 24 sessions each, alternating rounds to cancel
437
437
  // server-time noise): dropping the ?session_id= query improved it1
438
438
  // warmup-prefix hits 15/24 -> 22/24 and it2 full hits 11 -> 15 (miss
@@ -126,9 +126,8 @@ function _writeWsLifecycleTrace(lifecycle) {
126
126
  process.stderr.write(`[ws-trace] t=${new Date().toISOString()} lifecycle=${lifecycle}\n`);
127
127
  }
128
128
 
129
- // Wire-level `end_turn` on a terminal Responses frame (codex-rs
130
- // codex-api/src/sse/responses.rs ResponseCompleted.end_turn: Option<bool>,
131
- // consumed in core/src/session/turn.rs:2299 as "false ⇒ needs follow-up").
129
+ // Wire-level `end_turn` on a terminal Responses frame: an optional boolean on
130
+ // the completed response, where "false ⇒ needs follow-up".
132
131
  // The field is optional: only a real boolean is normalized; anything else —
133
132
  // including a missing field — stays undefined so absence is preserved and no
134
133
  // caller can mistake "server said nothing" for "server said true/false".
@@ -35,6 +35,12 @@ let _initChain = Promise.resolve();
35
35
  let _inFlightPromise = null;
36
36
  let _inFlightSig = null;
37
37
  let _lastAppliedSig = null;
38
+ // Provider instances are process-global inside the backend daemon. Catalog
39
+ // readers use this revision to share one raw model snapshot while still
40
+ // rebuilding their cheap route/config projection after auth/config changes.
41
+ let _providerCatalogRevision = 0;
42
+ let _startupCatalogRefreshPromise = null;
43
+ let _catalogRefreshPromise = null;
38
44
 
39
45
  // Deterministic structural signature of a provider config. Recursively sorts
40
46
  // object keys so signature equality reflects config-value equality regardless
@@ -141,7 +147,12 @@ export async function initProviders(config, { signal = null } = {}) {
141
147
  }
142
148
  };
143
149
  const tracked = next.then(
144
- (v) => { _lastAppliedSig = sig; settle(); return v; },
150
+ (v) => {
151
+ if (_lastAppliedSig !== sig) _providerCatalogRevision += 1;
152
+ _lastAppliedSig = sig;
153
+ settle();
154
+ return v;
155
+ },
145
156
  (err) => { settle(); throw err; },
146
157
  );
147
158
  _inFlightSig = sig;
@@ -236,6 +247,7 @@ export function getProvider(name) {
236
247
  if (!Ctor) return undefined;
237
248
  const inst = wrapProviderAdmission(new Ctor({}), name);
238
249
  providers.set(name, inst);
250
+ _providerCatalogRevision += 1;
239
251
  return inst;
240
252
  }
241
253
  if (name === 'openai-oauth' && hasOpenAIOAuthCredentials()) {
@@ -243,6 +255,7 @@ export function getProvider(name) {
243
255
  if (!Ctor) return undefined;
244
256
  const inst = wrapProviderAdmission(new Ctor({}), name);
245
257
  providers.set(name, inst);
258
+ _providerCatalogRevision += 1;
246
259
  return inst;
247
260
  }
248
261
  if (name === 'grok-oauth' && hasGrokOAuthCredentials()) {
@@ -250,6 +263,7 @@ export function getProvider(name) {
250
263
  if (!Ctor) return undefined;
251
264
  const inst = wrapProviderAdmission(new Ctor({}), name);
252
265
  providers.set(name, inst);
266
+ _providerCatalogRevision += 1;
253
267
  return inst;
254
268
  }
255
269
  return undefined;
@@ -287,6 +301,9 @@ export function getAllProviders() {
287
301
  // stale entries across re-init (initProviders rebuilds the map in place).
288
302
  return new Map(providers);
289
303
  }
304
+ export function providerCatalogRevision() {
305
+ return _providerCatalogRevision;
306
+ }
290
307
  // Narrow synchronous test seam for the lazy-OAuth boundary. It models a
291
308
  // constructor whose module is already loaded while guaranteeing every touched
292
309
  // registry entry is restored, even when the assertion callback throws.
@@ -311,6 +328,20 @@ export function _withLoadedProviderCtorForTest(name, Ctor, fn) {
311
328
  else signatures.delete(name);
312
329
  }
313
330
  }
331
+ // Companion seam for registry walks that iterate live INSTANCES (what
332
+ // initProviders leaves behind) rather than constructors — the startup catalog
333
+ // refresh is one. Restores the prior entry even when the callback throws.
334
+ export function _withRegisteredProviderForTest(name, instance, fn) {
335
+ const hadProvider = providers.has(name);
336
+ const priorProvider = providers.get(name);
337
+ providers.set(name, instance);
338
+ try {
339
+ return fn();
340
+ } finally {
341
+ if (hadProvider) providers.set(name, priorProvider);
342
+ else providers.delete(name);
343
+ }
344
+ }
314
345
  // Background catalog warm-up. Each provider's listModels() either hits its
315
346
  // own cached model list (no-op) or fires a single HTTP refresh. Called from
316
347
  // agent.init() after providers are registered so the first agent dispatch call
@@ -336,7 +367,9 @@ function warmupCatalogs() {
336
367
  }
337
368
  }
338
369
 
339
- // Force-refresh each provider's /models catalog on every MCP start. Unlike
370
+ // Force-refresh each provider's /models catalog ONCE per backend-daemon
371
+ // lifetime. Every session runtime joins this process-global promise and then
372
+ // reads the same provider-instance caches. Unlike
340
373
  // warmupCatalogs (which calls listModels() and so respects the 24h provider
341
374
  // TTL → no-op when the cache is fresh), this bypasses the TTL via
342
375
  // _refreshModelCache so a model released since the last refresh is picked up
@@ -345,6 +378,7 @@ function warmupCatalogs() {
345
378
  // context metadata stays on its own 24h TTL. Fire-and-forget: never awaited,
346
379
  // per-provider failures logged to stderr like warmupCatalogs.
347
380
  export function refreshProviderCatalogsOnStartup() {
381
+ if (_startupCatalogRefreshPromise) return _startupCatalogRefreshPromise;
348
382
  const pending = [];
349
383
  for (const [name, provider] of providers) {
350
384
  const refreshFn = typeof provider?._refreshModelCache === 'function'
@@ -361,7 +395,11 @@ export function refreshProviderCatalogsOnStartup() {
361
395
  // Returns a completion promise so callers can invalidate stale model
362
396
  // caches once the fresh catalogs land. Still fire-and-forget: unawaited
363
397
  // callers keep the previous nonblocking startup behavior.
364
- return Promise.allSettled(pending);
398
+ _startupCatalogRefreshPromise = Promise.allSettled(pending).then((results) => {
399
+ _providerCatalogRevision += 1;
400
+ return results;
401
+ });
402
+ return _startupCatalogRefreshPromise;
365
403
  }
366
404
 
367
405
  // Force-refresh provider catalogs after an operator changes model/provider
@@ -369,6 +407,7 @@ export function refreshProviderCatalogsOnStartup() {
369
407
  // the shared LiteLLM metadata cache first so context/pricing metadata follows
370
408
  // newly released models without waiting for the next process restart.
371
409
  export function refreshCatalogs() {
410
+ if (_catalogRefreshPromise) return _catalogRefreshPromise;
372
411
  const pending = [];
373
412
  const metadataReady = Promise.resolve()
374
413
  .then(() => refreshMetadataCatalog())
@@ -391,5 +430,13 @@ export function refreshCatalogs() {
391
430
  }));
392
431
  }
393
432
  // Completion promise: lets callers drop stale model caches after refresh.
394
- return Promise.allSettled([metadataReady, ...pending]);
433
+ _catalogRefreshPromise = Promise.allSettled([metadataReady, ...pending])
434
+ .then((results) => {
435
+ _providerCatalogRevision += 1;
436
+ return results;
437
+ })
438
+ .finally(() => {
439
+ _catalogRefreshPromise = null;
440
+ });
441
+ return _catalogRefreshPromise;
395
442
  }
@@ -252,7 +252,7 @@ function isRetryable(err) {
252
252
  return classifyError(err) === 'transient'
253
253
  }
254
254
 
255
- /** Claude Code compatible Anthropic request budget: 10 retries (11 attempts).
255
+ /** Anthropic request budget: 10 retries (11 attempts).
256
256
  * CLAUDE_CODE_MAX_RETRIES is intentionally read per request for reload/tests.
257
257
  * The upper bound prevents an accidental unbounded retry loop. */
258
258
  export function anthropicMaxAttempts() {
@@ -262,16 +262,16 @@ export function anthropicMaxAttempts() {
262
262
  return retries + 1
263
263
  }
264
264
 
265
- // Claude Code request defaults (withRetry.ts): 500ms exponential backoff,
265
+ // Anthropic retry defaults: 500ms exponential backoff,
266
266
  // capped at 32s, with positive-only jitter up to 25% of the base delay.
267
- // The leading duplicate accounts for withRetry's sleep-before-attempt index:
267
+ // The leading duplicate accounts for the sleep-before-attempt index:
268
268
  // retry attempt 2 reads index 1.
269
269
  export const ANTHROPIC_RETRY_BACKOFF_MS = Object.freeze([
270
270
  500, 500, 1000, 2000, 4000, 8000, 16000, 32000, 32000, 32000, 32000,
271
271
  ])
272
272
  export const ANTHROPIC_RETRY_JITTER_RATIO = 0.25
273
273
 
274
- // Claude Code's Anthropic SDK client defaults API_TIMEOUT_MS to ten minutes.
274
+ // The Anthropic SDK client defaults API_TIMEOUT_MS to ten minutes.
275
275
  // Read per request, like CLAUDE_CODE_MAX_RETRIES, so env reload/tests work.
276
276
  export function anthropicRequestTimeoutMs() {
277
277
  const parsed = Number.parseInt(process.env.API_TIMEOUT_MS || '', 10)
@@ -318,10 +318,10 @@ export function jitterDelayMs(ms, ratio = PROVIDER_RETRY_JITTER_RATIO, mode = 's
318
318
  // Mid-stream 'stream_stalled' recoveries retry in place, which is right for a
319
319
  // one-off blip but lets a chronically dying stream burn a whole task budget
320
320
  // slowly (observed live: one send stretched 149s→298s→556s across stall
321
- // retries before the agent deadline killed the task). Reference stacks bound
322
- // this instead of retrying forever: Claude Code caps each request at ~300s
323
- // wall clock (API_TIMEOUT_MS) and Codex kills a stream after one 300s silent
324
- // gap (stream_idle_timeout). This guard is the equivalent for our in-place
321
+ // retries before the agent deadline killed the task). A stalling stream is
322
+ // bounded instead of retried forever: a request is capped at ~300s wall
323
+ // clock (API_TIMEOUT_MS) and a stream dies after one 300s silent gap
324
+ // (stream idle timeout). This guard is the equivalent for our in-place
325
325
  // recovery: the clock starts at the FIRST stall of a send, and stall-classified
326
326
  // retries are allowed only inside that window; past it the stall error
327
327
  // surfaces so loop-level transport retry issues a FRESH request. Healthy
@@ -357,7 +357,7 @@ export function createStallRetryBudget(budgetMs = STREAM_STALL_RETRY_BUDGET_MS,
357
357
  // branched on a hardcoded provider name.
358
358
 
359
359
  // F) Retry-budget profiles as DATA. The numbers live ONLY here now.
360
- // ws.*Retries (5) — one Codex Responses stream retry budget.
360
+ // ws.*Retries (5) — one Responses stream retry budget.
361
361
  // sse.defaultRetries (3) — anthropic single-shot SSE mid-stream budget.
362
362
  export const MIDSTREAM_RETRY_POLICY = {
363
363
  ws: { transientCloseRetries: 5, defaultRetries: 5, backoff: [250, 1000, 2000, 4000, 5000] },
@@ -490,7 +490,7 @@ function _classifyMidstreamWs(err, state, attemptIndex, policy) {
490
490
  }
491
491
 
492
492
  // Explicit `response.failed` error codes/types that describe a transport-level
493
- // interruption (Codex retries these); every other code is terminal.
493
+ // interruption (these are retryable); every other code is terminal.
494
494
  const RESPONSE_FAILED_CODE_CLASSIFIERS = new Map([
495
495
  ['stream_disconnected', 'response_failed_disconnected'],
496
496
  ['network_error', 'response_failed_network'],
@@ -550,7 +550,7 @@ export function shouldFallbackTransport(err, { signal, enabled = true } = {}) {
550
550
  if (signal?.aborted) return false
551
551
  // Transport fallback re-issues the request on another transport: it is a
552
552
  // replay, so the exposure deny applies. Eligibility itself stays typed
553
- // (status / errno / classifier), matching Codex's WS→HTTPS switch.
553
+ // (status / errno / classifier) for the WS→HTTPS switch.
554
554
  if (readStreamOutcome(err).replaySafe !== true) return false
555
555
  const status = Number(err?.httpStatus || err?.status || 0)
556
556
  // 401 is auth recovery, never transport fallback. 426 is the explicit
@@ -660,7 +660,7 @@ function _sleepChunkWithAbort(ms, signal, sleepFn, abortMessage) {
660
660
  // E) Handshake classifier (moved here from openai-oauth-ws). Default-deny:
661
661
  // anything not recognized as transient returns null. HTTP 401 is reserved
662
662
  // for auth recovery and 426 for immediate HTTPS fallback. The OpenAI OAuth
663
- // caller opts out of 429 retries (Codex retry_429:false); all other callers
663
+ // caller opts out of 429 retries (retry429:false); all other callers
664
664
  // retain the historical retryable UnexpectedStatus policy.
665
665
  export function classifyHandshakeError(err, { retry429 = true } = {}) {
666
666
  if (!err) return null
@@ -764,8 +764,8 @@ export async function withRetry(fn, opts = {}) {
764
764
  // an eligible failure is actually retried remains the typed question
765
765
  // resolved by classifyError()/status below.
766
766
  if (readStreamOutcome(caught).replaySafe !== true) throw caught
767
- // Claude Code treats x-should-retry:false as an explicit server veto
768
- // (except an internal-only 5xx override that Mixdog does not have).
767
+ // x-should-retry:false is an explicit server veto on retrying and is
768
+ // honored as-is.
769
769
  // Keep this ahead of status defaults, including the request-local 429 path.
770
770
  const shouldRetryHeader = _headerValue(
771
771
  caught?.headers || caught?.response?.headers || caught?.data?.responseHeaders,
@@ -786,7 +786,7 @@ export async function withRetry(fn, opts = {}) {
786
786
  }
787
787
  continue
788
788
  }
789
- // Claude Code's optional model fallback fires on the third 529. This
789
+ // The optional model fallback fires on the third 529. This
790
790
  // remains opt-in: providers pass fallbackModel only when the caller set
791
791
  // one. The hard progress veto above must run first so fallback can never
792
792
  // replay partial thinking/tool output.
@@ -91,7 +91,6 @@ import {
91
91
  isOutputLimitStopReason,
92
92
  providerContinuationSignal,
93
93
  } from './loop/termination.mjs';
94
- import { createSteeringLadder } from './loop/steering-ladder.mjs';
95
94
  import { runPreSendCompactPass } from './pre-send-compact.mjs';
96
95
  import { createEagerDispatcher } from './eager-dispatch.mjs';
97
96
  import { sendWithRecovery } from './send-with-recovery.mjs';
@@ -238,7 +237,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
238
237
  if (compactSettledToolCallBodies(messages) && !opts.cacheBreakIntent) {
239
238
  opts.cacheBreakIntent = 'deferred_body_compaction';
240
239
  }
241
- // ---- Codex turn stop hook (refs/codex core/src/session/turn.rs:372-404) --
240
+ // ---- Turn stop hook ----------------------------------------------------
242
241
  // A no-tool assistant message is TERMINAL. Only a structured provider
243
242
  // follow-up signal (end_turn=false / pause_turn), pending input, tool
244
243
  // calls/results, or a stop hook that blocks with a continuation prompt keep
@@ -280,8 +279,8 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
280
279
  }
281
280
  // Tag steering-origin user messages so provider lowering keeps them
282
281
  // distinct from preceding tool results. Keep each queued command as
283
- // its own user turn, matching Claude Code queued_command attachment
284
- // semantics instead of collapsing priority/mode buckets together.
282
+ // its own user turn instead of collapsing priority/mode buckets
283
+ // together.
285
284
  messages.push({
286
285
  role: 'user',
287
286
  content: merged.content,
@@ -366,9 +365,10 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
366
365
  };
367
366
  const maxLoopIterations = resolveSessionMaxLoopIterations(sessionRef);
368
367
  // ---- Completion-first loop guards (worker runaway prevention) ----
369
- // Step 1 (escalation ladder) + the missed-parallelism / serial-rewording
370
- // steering hints live in the createSteeringLadder controller below; it owns
371
- // their cumulative counters and emits at most one hint per turn.
368
+ // Behavior-steering hints (missed-parallelism, all-read-only, read-only
369
+ // shell, level-2 "stop exploring") were removed: they nudged tool shape
370
+ // instead of protecting resources. Only the staged iteration warnings, the
371
+ // hard cap, and the cross-turn dedup stub remain.
372
372
  // _editCount counts any executed tool call whose def lacks readOnlyHint
373
373
  // (i.e. edit/progress: apply_patch, bash, MCP writes, skills, ...).
374
374
  let _editCount = 0;
@@ -406,7 +406,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
406
406
  // Loop-level transport replays consumed this ask (see send-with-recovery
407
407
  // TRANSPORT_RETRY_MAX): bounded per turn, reset only with a fresh ask.
408
408
  let _transportRetriesUsed = 0;
409
- // Claude Code parity: queued prompt/task notifications are attached after a
409
+ // Queued prompt/task notifications are attached after a
410
410
  // tool batch, before the continuation provider send. Normal batches drain
411
411
  // up to 'next'; a Sleep-like tool grants a 'later' flush.
412
412
  let _toolBatchJustCompleted = false;
@@ -415,20 +415,6 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
415
415
  const name = String(call?.name || call?.toolName || call?.function?.name || '').toLowerCase();
416
416
  return name === 'sleep' || name.endsWith('/sleep') || name.endsWith('.sleep');
417
417
  };
418
- // Completion-first steering ladder controller. Owns the (cumulative) level-1
419
- // fire count, the all-read-only / serial-single / same-file-grep streaks,
420
- // and the level-2 latch. Threaded via live getters so it reads the loop's
421
- // current `iterations` / `_editCount` on every call (no stale snapshots).
422
- const _steeringLadder = createSteeringLadder({
423
- sessionId,
424
- sessionAgent,
425
- tools,
426
- getIterations: () => iterations,
427
- getEditCount: () => _editCount,
428
- readOnlyRole: String(sessionRef?.permission || sessionRef?.toolPermission || '') === 'read',
429
- pushUserMessage: (msg) => messages.push(msg),
430
- pushSystemReminder: (text) => messages.push({ role: 'user', content: `<system-reminder>\n${text}\n</system-reminder>`, meta: 'hook' }),
431
- });
432
418
  // Tool execution must use the session cwd even when the caller omitted the
433
419
  // legacy positional cwd argument. Agent workers always carry their cwd on
434
420
  // sessionRef; falling through to pwd()/process.cwd() resolves relatives
@@ -503,8 +489,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
503
489
  } catch { /* best-effort */ }
504
490
  }
505
491
  // Drain queued steering/prompts BEFORE the pre-send compact check, but
506
- // only immediately after a tool batch has completed. This mirrors
507
- // Claude Code's query.ts queued_command attachment drain: queued entries
492
+ // only immediately after a tool batch has completed: queued entries
508
493
  // are attached after tool results are appended and before the recursive
509
494
  // continuation, not on arbitrary non-tool continuations (empty nudges,
510
495
  // iteration-cap final text turns, etc.).
@@ -792,11 +777,6 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
792
777
  // tool-call-blocked vs contract-required oscillation.
793
778
  if (!response.toolCalls?.length) {
794
779
  // No tool calls. Decide between final-answer accept vs nudge.
795
- // Reviewer fix: a zero-tool turn (final-pre-send steering drain or
796
- // contract nudge `continue`) must not bridge the all-read-only
797
- // streak across non-tool turns — that would fire level-2 early on
798
- // a worker that paused to synthesize text mid-run.
799
- _steeringLadder.resetAllReadOnlyStreak();
800
780
  // - has content + non-hidden role → valid final, break.
801
781
  // - empty content + hidden role → contract allows text-only
802
782
  // terminal turn, break.
@@ -915,7 +895,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
915
895
  messages.push({ role: 'user', content: nudgeMsg });
916
896
  continue;
917
897
  }
918
- // Codex `has_pending_input` (turn.rs:304-318): queued user input is
898
+ // Pending-input rule: queued user input is
919
899
  // folded into needs_follow_up and evaluated BEFORE the stop hooks,
920
900
  // so real steering always wins over a synthetic continuation prompt.
921
901
  // Commit the terminal text first (beforeAppend), then resume.
@@ -926,7 +906,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
926
906
  _emptyNudgeStreak = 0;
927
907
  continue;
928
908
  }
929
- // Codex parity (turn.rs:372-404): this no-tool message ends the turn
909
+ // This no-tool message ends the turn
930
910
  // unless a stop hook blocks it. The unresolved-tool-failure hook may
931
911
  // block exactly once — commit the assistant text, record the
932
912
  // structural continuation prompt, resume sampling. Skipped on the
@@ -1064,7 +1044,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1064
1044
  pending: eager.pending, epoch: eager.epoch, startEagerRun: eager.startEagerRun,
1065
1045
  crossTurnCalls: _crossTurnCalls, crossTurnCap: _CROSS_TURN_CAP,
1066
1046
  dedupStubTotal: _dedupStubTotal, editCount: _editCount,
1067
- sessionAgent, steeringLadder: _steeringLadder,
1047
+ sessionAgent,
1068
1048
  pushToolResultMessage, throwIfAborted,
1069
1049
  repeatFailLimit: REPEAT_FAIL_LIMIT,
1070
1050
  }));
@@ -3,6 +3,8 @@
3
3
  import { _normalizeAbs, _statTuple, _statEqual } from './util.mjs';
4
4
  import { clearScopedToolsForSession, clearScopedCounters } from './scoped-cache.mjs';
5
5
  import { clearPostEditMarks } from './post-edit-marks.mjs';
6
+ import { registerSessionPurgeHook } from '../store.mjs';
7
+ import { releaseReadSnapshotScope } from '../../tools/builtin/snapshot-store.mjs';
6
8
 
7
9
  const MAX_PER_SESSION = 100;
8
10
 
@@ -260,6 +262,11 @@ export function clearReadDedupSession(sessionId) {
260
262
  clearScopedCounters(sessionId);
261
263
  }
262
264
 
265
+ registerSessionPurgeHook((sessionId) => {
266
+ clearReadDedupSession(sessionId);
267
+ releaseReadSnapshotScope(sessionId, { deletePersisted: true, persist: false });
268
+ });
269
+
263
270
  /**
264
271
  * Extract the set of touched filesystem paths from a unified-diff patch text.
265
272
  * Handles git-style `--- a/<path>` / `+++ b/<path>` headers and `/dev/null` markers.