mixdog 1.2.1 → 1.2.2

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 (233) hide show
  1. package/package.json +1 -1
  2. package/scripts/gate-local.mjs +19 -3
  3. package/scripts/model-catalog-audit.mjs +1 -15
  4. package/src/agents/reviewer/AGENT.md +3 -3
  5. package/src/agents/reviewer/agent.json +1 -1
  6. package/src/defaults/shipped-history.json +1 -0
  7. package/src/repl/slash-commands.mjs +16 -1
  8. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +29 -4
  9. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +10 -0
  10. package/src/runtime/agent/orchestrator/mcp/client-config.mjs +36 -7
  11. package/src/runtime/agent/orchestrator/mcp/client-handshake.mjs +49 -10
  12. package/src/runtime/agent/orchestrator/mcp/client-transport.mjs +40 -3
  13. package/src/runtime/agent/orchestrator/mcp/client.mjs +230 -96
  14. package/src/runtime/agent/orchestrator/mcp/oauth-callback.mjs +103 -0
  15. package/src/runtime/agent/orchestrator/mcp/oauth-credentials.mjs +150 -0
  16. package/src/runtime/agent/orchestrator/mcp/oauth-login.mjs +111 -0
  17. package/src/runtime/agent/orchestrator/mcp/oauth-probe.mjs +87 -0
  18. package/src/runtime/agent/orchestrator/mcp/oauth-provider.mjs +95 -0
  19. package/src/runtime/agent/orchestrator/mcp/oauth-request.mjs +122 -0
  20. package/src/runtime/agent/orchestrator/mcp/oauth-shared.mjs +9 -0
  21. package/src/runtime/agent/orchestrator/mcp/oauth.mjs +229 -0
  22. package/src/runtime/agent/orchestrator/providers/account-pool.mjs +188 -29
  23. package/src/runtime/agent/orchestrator/providers/anthropic-api-recovery.mjs +2 -1
  24. package/src/runtime/agent/orchestrator/providers/anthropic-api-transport.mjs +94 -31
  25. package/src/runtime/agent/orchestrator/providers/anthropic-error-contract.mjs +96 -0
  26. package/src/runtime/agent/orchestrator/providers/anthropic-fast-mode.mjs +39 -18
  27. package/src/runtime/agent/orchestrator/providers/anthropic-max-tokens-recovery.mjs +55 -0
  28. package/src/runtime/agent/orchestrator/providers/anthropic-max-tokens.mjs +25 -21
  29. package/src/runtime/agent/orchestrator/providers/anthropic-messages.mjs +3 -1
  30. package/src/runtime/agent/orchestrator/providers/anthropic-midstream-recovery.mjs +18 -4
  31. package/src/runtime/agent/orchestrator/providers/anthropic-model-resolve.mjs +13 -1
  32. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-client-version.mjs +1 -1
  33. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-midstream.mjs +10 -1
  34. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-recovery.mjs +23 -9
  35. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-request/initial-status.mjs +46 -20
  36. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-request.mjs +14 -9
  37. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +73 -35
  38. package/src/runtime/agent/orchestrator/providers/anthropic-overload-fallback.mjs +69 -0
  39. package/src/runtime/agent/orchestrator/providers/anthropic-server-fallback.mjs +4 -0
  40. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +8 -1
  41. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +34 -7
  42. package/src/runtime/agent/orchestrator/providers/antigravity-oauth-catalog.mjs +30 -9
  43. package/src/runtime/agent/orchestrator/providers/antigravity-oauth.mjs +7 -0
  44. package/src/runtime/agent/orchestrator/providers/antigravity-request.mjs +30 -14
  45. package/src/runtime/agent/orchestrator/providers/antigravity-response.mjs +2 -1
  46. package/src/runtime/agent/orchestrator/providers/antigravity-retry-policy.mjs +93 -0
  47. package/src/runtime/agent/orchestrator/providers/antigravity-transport.mjs +82 -9
  48. package/src/runtime/agent/orchestrator/providers/command-code-oauth.mjs +3 -0
  49. package/src/runtime/agent/orchestrator/providers/cursor-auth.mjs +38 -5
  50. package/src/runtime/agent/orchestrator/providers/cursor-client-version.mjs +1 -1
  51. package/src/runtime/agent/orchestrator/providers/cursor-wire-guards.mjs +97 -2
  52. package/src/runtime/agent/orchestrator/providers/cursor-wire-stream-bridge.mjs +102 -23
  53. package/src/runtime/agent/orchestrator/providers/cursor-wire-transport.mjs +103 -33
  54. package/src/runtime/agent/orchestrator/providers/cursor-wire.mjs +29 -6
  55. package/src/runtime/agent/orchestrator/providers/cursor.mjs +18 -1
  56. package/src/runtime/agent/orchestrator/providers/effort-configuration.mjs +77 -17
  57. package/src/runtime/agent/orchestrator/providers/gemini-cache-policy.mjs +1 -1
  58. package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +29 -8
  59. package/src/runtime/agent/orchestrator/providers/gemini-error-contract.mjs +191 -0
  60. package/src/runtime/agent/orchestrator/providers/gemini-family.mjs +9 -0
  61. package/src/runtime/agent/orchestrator/providers/gemini-history-signatures.mjs +9 -4
  62. package/src/runtime/agent/orchestrator/providers/gemini-response.mjs +3 -1
  63. package/src/runtime/agent/orchestrator/providers/gemini-retry-policy.mjs +180 -0
  64. package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +21 -82
  65. package/src/runtime/agent/orchestrator/providers/gemini-sdk-request.mjs +4 -1
  66. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +71 -12
  67. package/src/runtime/agent/orchestrator/providers/gemini-thinking.mjs +21 -20
  68. package/src/runtime/agent/orchestrator/providers/gemini.mjs +56 -23
  69. package/src/runtime/agent/orchestrator/providers/grok-oauth-tokens.mjs +3 -0
  70. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +68 -19
  71. package/src/runtime/agent/orchestrator/providers/mimo-oauth.mjs +3 -0
  72. package/src/runtime/agent/orchestrator/providers/model-list-sanitize.mjs +2 -1
  73. package/src/runtime/agent/orchestrator/providers/muse-code-oauth.mjs +4 -9
  74. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-send.mjs +4 -2
  75. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-stream/stream-result.mjs +22 -0
  76. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-stream.mjs +1 -0
  77. package/src/runtime/agent/orchestrator/providers/openai-compat-error-contract.mjs +50 -0
  78. package/src/runtime/agent/orchestrator/providers/openai-compat-response-normalization.mjs +23 -0
  79. package/src/runtime/agent/orchestrator/providers/openai-compat-responses-events.mjs +16 -8
  80. package/src/runtime/agent/orchestrator/providers/openai-compat-responses-state.mjs +3 -2
  81. package/src/runtime/agent/orchestrator/providers/openai-compat-responses.mjs +94 -13
  82. package/src/runtime/agent/orchestrator/providers/openai-compat-retry-profile.mjs +193 -0
  83. package/src/runtime/agent/orchestrator/providers/openai-compat-stream-common.mjs +66 -2
  84. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +2 -1
  85. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +6 -0
  86. package/src/runtime/agent/orchestrator/providers/openai-compat-xai-send.mjs +4 -2
  87. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +23 -14
  88. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +17 -3
  89. package/src/runtime/agent/orchestrator/providers/openai-direct-request.mjs +42 -25
  90. package/src/runtime/agent/orchestrator/providers/openai-error-contract.mjs +274 -0
  91. package/src/runtime/agent/orchestrator/providers/openai-http-sse-request.mjs +72 -32
  92. package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-events/terminal-frames.mjs +18 -11
  93. package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-outcome.mjs +1 -2
  94. package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-stamps.mjs +2 -2
  95. package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-state.mjs +2 -3
  96. package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state.mjs +2 -2
  97. package/src/runtime/agent/orchestrator/providers/openai-http-sse-watchdogs.mjs +1 -3
  98. package/src/runtime/agent/orchestrator/providers/openai-oauth-dispatch.mjs +34 -3
  99. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +5 -1
  100. package/src/runtime/agent/orchestrator/providers/openai-oauth-tokens.mjs +22 -2
  101. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +5 -0
  102. package/src/runtime/agent/orchestrator/providers/openai-responses-input.mjs +5 -9
  103. package/src/runtime/agent/orchestrator/providers/openai-retry-policy.mjs +9 -0
  104. package/src/runtime/agent/orchestrator/providers/openai-ws-handshake.mjs +16 -8
  105. package/src/runtime/agent/orchestrator/providers/openai-ws-send-attempts/attempt-context.mjs +1 -2
  106. package/src/runtime/agent/orchestrator/providers/openai-ws-send-attempts/policy.mjs +25 -14
  107. package/src/runtime/agent/orchestrator/providers/openai-ws-terminal.mjs +18 -11
  108. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +31 -20
  109. package/src/runtime/agent/orchestrator/providers/provider-error-kind.mjs +581 -0
  110. package/src/runtime/agent/orchestrator/providers/retry-backoff.mjs +46 -7
  111. package/src/runtime/agent/orchestrator/providers/retry-classification.mjs +118 -219
  112. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +2 -0
  113. package/src/runtime/agent/orchestrator/providers/retry-executor.mjs +148 -45
  114. package/src/runtime/agent/orchestrator/providers/retry-midstream.mjs +81 -16
  115. package/src/runtime/agent/orchestrator/runtime-core/config-helpers.mjs +3 -0
  116. package/src/runtime/agent/orchestrator/runtime-core/goal-reminder.mjs +0 -18
  117. package/src/runtime/agent/orchestrator/runtime-core/model-capabilities.mjs +57 -28
  118. package/src/runtime/agent/orchestrator/runtime-core/orchestration.mjs +19 -16
  119. package/src/runtime/agent/orchestrator/runtime-core/plugin-mcp.mjs +1 -97
  120. package/src/runtime/agent/orchestrator/session/catalog-db/schema.mjs +3 -1
  121. package/src/runtime/agent/orchestrator/session/loop/send-phase.mjs +4 -0
  122. package/src/runtime/agent/orchestrator/session/loop/step-auto-effort.mjs +102 -18
  123. package/src/runtime/agent/orchestrator/session/loop/tool-phase.mjs +3 -1
  124. package/src/runtime/agent/orchestrator/session/manager/ask-queue.mjs +30 -2
  125. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +11 -2
  126. package/src/runtime/agent/orchestrator/session/manager/ask-turn-execute.mjs +41 -21
  127. package/src/runtime/agent/orchestrator/session/manager/pending-message-entry.mjs +5 -10
  128. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +1 -1
  129. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +18 -7
  130. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +155 -29
  131. package/src/runtime/agent/orchestrator/session/store/save-worker.mjs +6 -3
  132. package/src/runtime/agent/orchestrator/session/store/write-guards.mjs +16 -0
  133. package/src/runtime/agent/orchestrator/session/store-agent-worker-rows.mjs +22 -10
  134. package/src/runtime/agent/orchestrator/session/store-summary-catalog.mjs +2 -0
  135. package/src/runtime/agent/orchestrator/session/store-summary-fields.mjs +5 -0
  136. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +3 -0
  137. package/src/runtime/agent/orchestrator/session/transcript-restore/restore.mjs +20 -1
  138. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +2 -2
  139. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/deadline-plan.mjs +12 -15
  140. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +5 -5
  141. package/src/runtime/memory/lib/pg/process.mjs +34 -24
  142. package/src/runtime/memory/lib/pg/supervisor.mjs +16 -13
  143. package/src/runtime/office/README.md +1 -1
  144. package/src/runtime/office/shared/values.mjs +0 -5
  145. package/src/runtime/shared/background-tasks.mjs +92 -14
  146. package/src/runtime/shared/err-text.mjs +25 -0
  147. package/src/runtime/shared/error-presentation.mjs +159 -19
  148. package/src/runtime/shared/llm/anthropic-betas.mjs +4 -3
  149. package/src/runtime/shared/llm/anthropic-effort.mjs +123 -134
  150. package/src/runtime/shared/llm/anthropic-thinking-contract.mjs +50 -14
  151. package/src/runtime/shared/llm/model-catalog.mjs +53 -40
  152. package/src/runtime/shared/llm/openai-daybreak.mjs +4 -16
  153. package/src/runtime/shared/llm/provider-catalog-registry.mjs +11 -0
  154. package/src/runtime/shared/llm/provider-model-identities.mjs +13 -4
  155. package/src/runtime/shared/llm/usage-rollup.mjs +0 -1
  156. package/src/runtime/shared/model-display.mjs +13 -0
  157. package/src/runtime/shared/quota-resume-store.mjs +11 -0
  158. package/src/runtime/shared/session-baseline-store.mjs +960 -0
  159. package/src/runtime/shared/statusline/statusline-format.mjs +4 -4
  160. package/src/runtime/shared/task-notification-envelope.mjs +2 -2
  161. package/src/runtime/shared/turn-changes.mjs +447 -219
  162. package/src/runtime/shared/turn-snapshot-store.mjs +182 -1
  163. package/src/runtime/shared/turn-snapshot.mjs +170 -48
  164. package/src/runtime/shared/turn-worktree-snapshot.mjs +135 -46
  165. package/src/runtime/shared/worktrees/contract.mjs +0 -24
  166. package/src/runtime/shared/worktrees/test-support.mjs +19 -4
  167. package/src/session-runtime/boot/apis.mjs +6 -0
  168. package/src/session-runtime/boot/infrastructure.mjs +6 -0
  169. package/src/session-runtime/lifecycle/session-catalog.mjs +2 -0
  170. package/src/session-runtime/lifecycle/teardown.mjs +1 -1
  171. package/src/session-runtime/mcp-glue/connect.mjs +29 -9
  172. package/src/session-runtime/mcp-glue/server-catalog.mjs +20 -3
  173. package/src/session-runtime/mcp-glue.mjs +3 -0
  174. package/src/session-runtime/mcp-oauth-login.mjs +174 -0
  175. package/src/session-runtime/model-route/model-switch-target.mjs +34 -0
  176. package/src/session-runtime/model-route/set-route.mjs +83 -15
  177. package/src/session-runtime/model-route/web-search-route.mjs +1 -1
  178. package/src/session-runtime/native-web-search/routes.mjs +22 -3
  179. package/src/session-runtime/provider-models/model-meta.mjs +12 -3
  180. package/src/session-runtime/quick-model-rows/web-search-rows.mjs +13 -8
  181. package/src/session-runtime/quick-web-search-models.mjs +13 -38
  182. package/src/session-runtime/resource-extension-sync.mjs +4 -0
  183. package/src/session-runtime/resource-mcp-api.mjs +98 -2
  184. package/src/session-runtime/runtime-review-api.mjs +13 -0
  185. package/src/session-runtime/services/agent-tool/job-task-reconcile.mjs +13 -1
  186. package/src/session-runtime/services/agent-tool/job-views/agent-list.mjs +7 -2
  187. package/src/session-runtime/services/agent-tool/send-flow.mjs +22 -4
  188. package/src/session-runtime/services/agent-tool/spawn-flow/job-start.mjs +2 -0
  189. package/src/session-runtime/services/agent-tool/spawn-flow/spawn-run.mjs +22 -5
  190. package/src/session-runtime/services/agent-tool/worker-index/row-liveness.mjs +8 -1
  191. package/src/session-runtime/services/agent-tool/worker-index/row-shape.mjs +2 -2
  192. package/src/session-runtime/services/agent-tool/worker-rows.mjs +17 -0
  193. package/src/session-runtime/settings-compaction-api.mjs +1 -0
  194. package/src/standalone/daemon-drain-policy.mjs +8 -0
  195. package/src/standalone/daemon-telemetry.mjs +55 -0
  196. package/src/standalone/daemon.mjs +3 -7
  197. package/src/standalone/session-protocol.mjs +7 -0
  198. package/src/standalone/session-service/agent-tree/agent-cancel.mjs +33 -2
  199. package/src/standalone/session-service/agent-tree/agent-turns.mjs +26 -10
  200. package/src/standalone/session-service/agent-tree.mjs +12 -0
  201. package/src/standalone/session-service/quota-resume.mjs +8 -0
  202. package/src/standalone/session-service/retention/busy.mjs +15 -1
  203. package/src/standalone/session-service-api.mjs +5 -0
  204. package/src/standalone/session-service.mjs +1 -0
  205. package/src/tui/app/app-view.jsx +1 -0
  206. package/src/tui/app/create-app-pickers.mjs +4 -0
  207. package/src/tui/app/extension-pickers/mcp-login.mjs +107 -0
  208. package/src/tui/app/extension-pickers/mcp-servers-picker.mjs +2 -1
  209. package/src/tui/app/extension-pickers.mjs +2 -1
  210. package/src/tui/app/model-options.mjs +11 -16
  211. package/src/tui/app/slash-commands.mjs +7 -2
  212. package/src/tui/app/slash-dispatch/panel-commands.mjs +16 -1
  213. package/src/tui/components/StatusLine.jsx +2 -0
  214. package/src/tui/components/status-line/local-line.mjs +5 -2
  215. package/src/tui/components/status-line/use-statusline-text.mjs +7 -1
  216. package/src/tui/dist/index.mjs +816 -43
  217. package/src/tui/session/quota-resume.mjs +46 -14
  218. package/src/tui/session/session-api/commands.mjs +33 -24
  219. package/src/tui/session/session-api/extensions.mjs +22 -0
  220. package/src/tui/session/session-api/integrations.mjs +7 -0
  221. package/src/tui/session/session-api/lifecycle.mjs +5 -1
  222. package/src/tui/session/session-api/routes.mjs +71 -7
  223. package/src/tui/session/session-api/settings.mjs +5 -14
  224. package/src/tui/session/session-flow/drain.mjs +7 -1
  225. package/src/tui/session/session-flow/failed-turn-rewind.mjs +10 -4
  226. package/src/tui/session/turn.mjs +38 -15
  227. package/src/tui/statusline-ansi-bridge.mjs +1 -1
  228. package/src/ui/statusline-agents.mjs +15 -4
  229. package/src/ui/statusline.mjs +7 -3
  230. package/src/ui/streaming-markdown-heal.mjs +34 -3
  231. package/src/vendor/statusline/src/gateway/claude-current.mjs +14 -4
  232. package/src/runtime/memory/lib/memory-session-merge.mjs +0 -32
  233. package/src/runtime/shared/llm/usage-session-history.mjs +0 -266
@@ -12,6 +12,7 @@ import {
12
12
  isRetryableWireErrorEvent,
13
13
  isRetryableStreamErrorEvent,
14
14
  isProviderRecoveryExhausted,
15
+ isProviderRecoveryTerminal,
15
16
  isServerUnavailable,
16
17
  isStreamCut,
17
18
  isToolInputCut,
@@ -20,7 +21,9 @@ import {
20
21
  resetStallRetryBudget,
21
22
  resolveStallRetryBudget,
22
23
  retryAfterMsFromError,
24
+ sleepWithAbort,
23
25
  } from '../providers/retry-classifier.mjs';
26
+ import { DETERMINISTIC_KINDS, providerErrorKind } from '../providers/provider-error-kind.mjs';
24
27
  import {
25
28
  persistsConfirmedImageRejection,
26
29
  promptHasInlineImages,
@@ -34,7 +37,6 @@ import {
34
37
  isThinkingReplayRejection,
35
38
  repairThinkingReplayInPlace,
36
39
  } from './thinking-replay-recovery.mjs';
37
- import { setTimeout as sleepMs } from 'node:timers/promises';
38
40
  import { readStreamOutcome } from '../providers/lib/stream-outcome.mjs';
39
41
  import { cloneProviderReplay } from '../providers/lib/provider-replay.mjs';
40
42
  import { resolveWorkerCompactPolicy } from './loop/compact-policy.mjs';
@@ -126,10 +128,10 @@ const RECOVERY_RETRY_BACKOFF_MS = retryLadderFromEnv(
126
128
  [15_000, 30_000, 60_000, 60_000, 60_000]
127
129
  );
128
130
 
129
- // A server-advised wait (Retry-After) replaces the ladder step: a provider that
130
- // names its own cooldown knows better, within reason — one loop-level wait
131
- // never parks longer than this.
132
- const LOOP_RETRY_AFTER_CAP_MS = 120_000;
131
+ // A server-advised wait (Retry-After / retryAfterMs) replaces the ladder step
132
+ // and is honored in full, never shortened. The ladder length
133
+ // still bounds how many replays run, the wait stays cancellable, and the
134
+ // reconnecting stage reports its remaining delay (retryAt).
133
135
 
134
136
  // Jittering every backoff by 0.9–1.1 means parallel workers that lose the
135
137
  // same uplink do not resume in lockstep; our provider layers already jitter
@@ -138,14 +140,42 @@ const TRANSPORT_RETRY_JITTER_RATIO = 0.1;
138
140
 
139
141
  /** The ladder this failure class is entitled to: an unreachable network, a
140
142
  * provider still unavailable after its own retries, or an ordinary fault. */
141
- function retryLadderFor(error) {
143
+ function retryLadderFor(error, state = null) {
142
144
  if (isConnectFailure(error)) return CONNECTION_RETRY_BACKOFF_MS;
143
- if (isProviderRecoveryExhausted(error) && isServerUnavailable(error)) return RECOVERY_RETRY_BACKOFF_MS;
145
+ if (isProviderRecoveryExhausted(error) && isServerUnavailable(error) && recoveryCyclesEnabled(state)) {
146
+ return RECOVERY_RETRY_BACKOFF_MS;
147
+ }
144
148
  return TRANSPORT_RETRY_BACKOFF_MS;
145
149
  }
146
150
 
147
- function retryBudgetFor(error) {
148
- return retryLadderFor(error).length;
151
+ // retryPolicy.recoveryCycles:false — a provider that already ran its own full
152
+ // ladder surfaces its spent failure instead of parking on
153
+ // the 15/30/60/60/60s recovery cycles.
154
+ function recoveryCyclesEnabled(state) {
155
+ return loopRetryPolicy(state).recoveryCycles !== false;
156
+ }
157
+
158
+ // Per-provider loop retry policy, read off the provider object
159
+ // (`provider.retryPolicy`), default = the bounded ladders:
160
+ // unboundedConnectionRetry — a connect failure keeps waiting on the
161
+ // 5/10/20/40/60s… ladder (last step repeating) until the network returns;
162
+ // still cancellable, with "waiting for network" progress.
163
+ // contentFilterRetryable — a content_filter refusal is replayed
164
+ // under the transport budget with a contextual guidance notice.
165
+ // inBandInvalidRequestRetryable — an in-band stream error whose only
166
+ // deterministic evidence is its invalid_request envelope is replayed.
167
+ function loopRetryPolicy(state) {
168
+ const policy = state?.provider?.retryPolicy;
169
+ return policy && typeof policy === 'object' ? policy : {};
170
+ }
171
+
172
+ function waitsForNetwork(error, state) {
173
+ return loopRetryPolicy(state).unboundedConnectionRetry === true && isConnectFailure(error);
174
+ }
175
+
176
+ function retryBudgetFor(error, state = null) {
177
+ if (waitsForNetwork(error, state)) return Number.POSITIVE_INFINITY;
178
+ return retryLadderFor(error, state).length;
149
179
  }
150
180
 
151
181
  // A provider ladder spent on a connect failure has only shown that the network
@@ -153,15 +183,19 @@ function retryBudgetFor(error) {
153
183
  // unavailable parks on the recovery cycles, but only while nothing has reached
154
184
  // the user — a replay would otherwise re-show output or re-run a tool. Every
155
185
  // other exhausted failure surfaces as-is.
156
- function providerRecoveryEnded(error, outcome) {
186
+ // A provider VERDICT that recovery is over (an advice-gated denial, the
187
+ // repeated-overload stop) always ends it: the loop never overrides it.
188
+ function providerRecoveryEnded(error, outcome, state = null) {
189
+ if (isProviderRecoveryTerminal(error)) return true;
157
190
  if (!isProviderRecoveryExhausted(error) || isConnectFailure(error)) return false;
191
+ if (!recoveryCyclesEnabled(state)) return true;
158
192
  return !(outcome.replaySafe === true && isServerUnavailable(error));
159
193
  }
160
194
 
161
- function transportRetryWaitMs(attemptIndex, error) {
195
+ function transportRetryWaitMs(attemptIndex, error, state = null) {
162
196
  const advisedMs = retryAfterMsFromError(error);
163
- if (advisedMs != null) return Math.min(advisedMs, LOOP_RETRY_AFTER_CAP_MS);
164
- const ladder = retryLadderFor(error);
197
+ if (advisedMs != null) return advisedMs;
198
+ const ladder = retryLadderFor(error, state);
165
199
  const base = ladder[Math.min(Math.max(attemptIndex, 0), ladder.length - 1)];
166
200
  return jitterDelayMs(base, TRANSPORT_RETRY_JITTER_RATIO);
167
201
  }
@@ -258,6 +292,46 @@ function traceLoop({ sessionId, nextIteration }, kind, fields) {
258
292
  // 5s/15s wait rendered as a frozen turn with no explanation, so the stage
259
293
  // change surfaces "Reconnecting... n/max" instead of leaving the user
260
294
  // staring at a stalled stream.
295
+ // Unbounded connect-failure wait (retryPolicy.unboundedConnectionRetry): no
296
+ // "n/max" — the turn waits until the network is back, counting down each step.
297
+ function emitWaitingForNetwork(opts, { attempt, waitMs, classifier, error }) {
298
+ if (typeof opts?.onStageChange !== 'function') return;
299
+ try {
300
+ const wait = Math.max(0, Number(waitMs) || 0);
301
+ opts.onStageChange('reconnecting', {
302
+ attempt,
303
+ max: null,
304
+ waitMs: wait,
305
+ retryAt: Date.now() + wait,
306
+ reason: 'waiting for network',
307
+ waitingForNetwork: true,
308
+ classifier: classifier || error?.code || null,
309
+ message: `Reconnecting… waiting for network · attempt ${attempt}${wait > 0 ? ` in ${Math.ceil(wait / 1000)}s` : ''}`,
310
+ });
311
+ } catch {
312
+ /* display-only */
313
+ }
314
+ }
315
+
316
+ // content_filter on a path that opted in (retryPolicy.contentFilterRetryable,
317
+ // or the provider boundary stamping `contentFilterRetryable` on the error).
318
+ // A provider-terminal verdict outranks the opt-in.
319
+ function isReplayableContentFilter(sendErr, state) {
320
+ if (isProviderRecoveryTerminal(sendErr)) return false;
321
+ const optedIn = loopRetryPolicy(state).contentFilterRetryable === true || sendErr?.contentFilterRetryable === true;
322
+ return optedIn && providerErrorKind(sendErr).code === 'content_filter';
323
+ }
324
+
325
+ // The guidance travels as a contextual runtime fragment: the
326
+ // [mixdog-runtime] prefix keeps it out of the visible transcript.
327
+ const CONTENT_FILTER_NOTICE =
328
+ '[mixdog-runtime] The previous response was stopped by the provider content filter before it completed. ' +
329
+ 'Continue the task, but rephrase or avoid the content that likely triggered the filter; do not repeat it verbatim.';
330
+
331
+ function contentFilterNotice() {
332
+ return { role: 'user', content: CONTENT_FILTER_NOTICE, meta: { source: 'content-filter-recovery' } };
333
+ }
334
+
261
335
  function emitLoopReconnectProgress(opts, { attempt, maxAttempts, waitMs, classifier, error }) {
262
336
  emitProviderRetryStage(opts?.onStageChange, {
263
337
  attempt,
@@ -353,10 +427,22 @@ async function recoverFromSendError(sendErr, state) {
353
427
  }
354
428
  // Clean transient transport failure with zero exposure: replay the send
355
429
  // after a bounded wait instead of failing the turn.
430
+ if (isReplayableContentFilter(sendErr, state)) {
431
+ if (transportRetriesUsed < TRANSPORT_RETRY_MAX && (outcome.replaySafe === true || (await retract()))) {
432
+ return scheduleTransportReplay(sendErr, state, {
433
+ budget: TRANSPORT_RETRY_MAX,
434
+ note: `[loop] content filter stopped the response (${loopTag(state)}); replaying with guidance`,
435
+ trace: { kind: 'content_filter_retry' },
436
+ classifier: 'content_filter',
437
+ notice: contentFilterNotice(),
438
+ });
439
+ }
440
+ throw sendErr;
441
+ }
356
442
  if (
357
- transportRetriesUsed < retryBudgetFor(sendErr) &&
358
- !providerRecoveryEnded(sendErr, outcome) &&
359
- (await transportReplayPermitted(sendErr, outcome, retract))
443
+ transportRetriesUsed < retryBudgetFor(sendErr, state) &&
444
+ !providerRecoveryEnded(sendErr, outcome, state) &&
445
+ (await transportReplayPermitted(sendErr, outcome, retract, state))
360
446
  ) {
361
447
  return retryTransport(sendErr, state);
362
448
  }
@@ -373,7 +459,7 @@ async function recoverFromSendError(sendErr, state) {
373
459
  ) {
374
460
  return repairThinkingReplay(recoveryMessages, state);
375
461
  }
376
- // Grok Build RetryWithImageStrip: drop inline images and replay once. Safe
462
+ // Image-strip retry: drop inline images and replay once. Safe
377
463
  // only when no tool was dispatched. Text exposure must be retracted first;
378
464
  // reasoning-only high-effort streams can retry without a text reset.
379
465
  if (
@@ -464,7 +550,7 @@ function isExposedNoToolStall(sendErr, outcome) {
464
550
  // 'transient' and never fired).
465
551
  async function recoverExposedStall(sendErr, outcome, state, retract) {
466
552
  const { transportRetriesUsed = 0 } = state;
467
- const exposedRetryBudget = retryBudgetFor(sendErr);
553
+ const exposedRetryBudget = retryBudgetFor(sendErr, state);
468
554
  const len = sendErr.partialContent.length;
469
555
  if (transportRetriesUsed < exposedRetryBudget && !isProviderRecoveryExhausted(sendErr) && (await retract())) {
470
556
  return scheduleTransportReplay(sendErr, state, {
@@ -491,12 +577,17 @@ async function recoverExposedStall(sendErr, outcome, state, retract) {
491
577
  async function scheduleTransportReplay(sendErr, state, { budget, note, trace, classifier, notice = null }) {
492
578
  const { opts, signal, transportRetriesUsed = 0 } = state;
493
579
  const attempt = transportRetriesUsed + 1;
494
- const waitMs = transportRetryWaitMs(transportRetriesUsed, sendErr);
580
+ const waitMs = transportRetryWaitMs(transportRetriesUsed, sendErr, state);
495
581
  logLoop(`${note}; transport retry ${attempt}/${budget} after ${waitMs}ms`);
496
582
  const { kind, ...fields } = trace;
497
583
  traceLoop(state, kind, { attempt, waitMs, ...fields });
498
- emitLoopReconnectProgress(opts, { attempt, maxAttempts: budget, waitMs, classifier, error: sendErr });
499
- await sleepMs(waitMs, undefined, signal ? { signal } : undefined);
584
+ if (Number.isFinite(budget)) {
585
+ emitLoopReconnectProgress(opts, { attempt, maxAttempts: budget, waitMs, classifier, error: sendErr });
586
+ } else {
587
+ emitWaitingForNetwork(opts, { attempt, waitMs, classifier, error: sendErr });
588
+ }
589
+ // Chunked, abortable sleep: honored advice may exceed one timer's range.
590
+ await sleepWithAbort(waitMs, signal, undefined, 'transport retry wait aborted');
500
591
  return beginFreshTransportAttempt(opts, budget, notice);
501
592
  }
502
593
 
@@ -542,7 +633,7 @@ function continueFromShownText(sendErr, state, relayWitness) {
542
633
  // budget still bounds it.
543
634
  async function recoverToolInputCut(sendErr, outcome, state, retract) {
544
635
  const { transportRetriesUsed = 0 } = state;
545
- const budget = retryBudgetFor(sendErr);
636
+ const budget = retryBudgetFor(sendErr, state);
546
637
  if (transportRetriesUsed < budget && (outcome.replaySafe === true || (await retract()))) {
547
638
  return scheduleTransportReplay(sendErr, state, {
548
639
  budget,
@@ -650,20 +741,55 @@ function recoverPartialToolCalls(sendErr, state, toolCalls, outcome) {
650
741
  // reasoning/text exposure makes classifyError() intentionally permanent,
651
742
  // non-terminal close, or a dropped connection (undici `terminated`, socket
652
743
  // reset) — then retract the exposed text and replay.
653
- async function transportReplayPermitted(sendErr, outcome, retract) {
654
- if (outcome.replaySafe === true && classifyError(sendErr) === 'transient') return true;
655
- const transportSymptom =
744
+ async function transportReplayPermitted(sendErr, outcome, retract, state) {
745
+ if (!isSemanticallyRetryable(sendErr, state)) return false;
746
+ // Existing replay-safety gate: an unexposed failure replays outright only
747
+ // when it is typed-transient (or an opted-in in-band invalid_request); a
748
+ // named symptom replays after retraction.
749
+ if (
750
+ outcome.replaySafe === true &&
751
+ (classifyError(sendErr) === 'transient' || inBandInvalidRequestReplay(sendErr, state))
752
+ ) {
753
+ return true;
754
+ }
755
+ return await retract();
756
+ }
757
+
758
+ // The ONE semantic verdict on whether this failure is worth re-issuing,
759
+ // independent of what was exposed (the replay-safety gate decides that
760
+ // afterwards): never a deterministic refusal (quota, plan, billing, policy,
761
+ // auth, permission, context or size) — server advice never makes one
762
+ // retryable — and otherwise a named transient/transport symptom.
763
+ function inBandInvalidRequestReplay(sendErr, state) {
764
+ return (
765
+ loopRetryPolicy(state).inBandInvalidRequestRetryable === true &&
766
+ providerErrorKind(sendErr).kind === 'invalid_request' &&
767
+ isRetryableStreamErrorEvent(sendErr)
768
+ );
769
+ }
770
+
771
+ function isSemanticallyRetryable(sendErr, state) {
772
+ if (isProviderRecoveryTerminal(sendErr)) return false;
773
+ // Opt-in (retryPolicy.inBandInvalidRequestRetryable): a provider whose
774
+ // in-band stream errors carry an invalid_request envelope for server faults
775
+ // replays one with no other deterministic evidence; for every other
776
+ // provider it is a final refusal.
777
+ const { kind } = providerErrorKind(sendErr);
778
+ if (DETERMINISTIC_KINDS.has(kind) && !inBandInvalidRequestReplay(sendErr, state)) {
779
+ return false;
780
+ }
781
+ return (
656
782
  classifyError(sendErr) === 'transient' ||
657
783
  isStreamCut(sendErr) ||
658
784
  isRetryableWireErrorEvent(sendErr) ||
659
785
  isRetryableStreamErrorEvent(sendErr) ||
660
- isCursorTransientTransportError(sendErr);
661
- return transportSymptom && (await retract());
786
+ isCursorTransientTransportError(sendErr)
787
+ );
662
788
  }
663
789
 
664
790
  function retryTransport(sendErr, state) {
665
791
  return scheduleTransportReplay(sendErr, state, {
666
- budget: retryBudgetFor(sendErr),
792
+ budget: retryBudgetFor(sendErr, state),
667
793
  note:
668
794
  `[loop] transient send failure with no dispatched tools (${loopTag(state)} code=${sendErr?.code ?? 'n/a'} ` +
669
795
  `wsCloseCode=${sendErr?.wsCloseCode ?? 'n/a'} httpStatus=${sendErr?.httpStatus ?? sendErr?.status ?? 'n/a'})`,
@@ -700,7 +826,7 @@ function imageStripEligible(
700
826
  hasImages: promptHasInlineImages(recoveryMessages),
701
827
  alreadyStripped: imageStripUsed === true,
702
828
  }) ||
703
- // Grok Build RetryWithImageStrip only: a bare stream error on a Grok
829
+ // Grok only: a bare stream error on a Grok
704
830
  // upload may be the image body being rejected. Other providers' transient
705
831
  // stream errors are not evidence about images and use normal transport retry.
706
832
  (isGrok &&
@@ -1,5 +1,5 @@
1
1
  import { MessageChannel, Worker } from 'node:worker_threads';
2
- import { guardedSaveOptions as _guardedSaveOptions } from './write-guards.mjs';
2
+ import { guardedSaveOptions as _guardedSaveOptions, releaseSessionWriteControl } from './write-guards.mjs';
3
3
  import { _ensureLifecycleFields } from './serialize.mjs';
4
4
  import {
5
5
  _attemptEvidence,
@@ -63,9 +63,12 @@ export { _hasSessionSaveBaseline } from './save-delta.mjs';
63
63
  * the writes, so a write already posted for `id` still lands first; the next
64
64
  * save for `id`, if any, simply goes out as a full snapshot.
65
65
  */
66
- export function forgetSessionSaveBaseline(id) {
66
+ export function forgetSessionSaveBaseline(id, { releaseWriteControl = false } = {}) {
67
67
  if (!id) return false;
68
68
  const had = _dropDeltaBaseline(id);
69
+ // Only when the session itself is gone (close/teardown/delete): a mid-life
70
+ // forget (compaction) must keep the landed-epoch fence for in-flight writes.
71
+ if (releaseWriteControl) releaseSessionWriteControl(String(id));
69
72
  if (_saveWorker) {
70
73
  try {
71
74
  _saveWorker.postMessage({ __forgetBase: String(id) });
@@ -854,7 +857,7 @@ export function purgeSessionSaveBookkeeping(id) {
854
857
  }
855
858
  }
856
859
  // Also frees the worker-side base: a deleted id never saves again.
857
- forgetSessionSaveBaseline(id);
860
+ forgetSessionSaveBaseline(id, { releaseWriteControl: true });
858
861
  return purged;
859
862
  }
860
863
 
@@ -49,6 +49,22 @@ export function guardedSaveOptions(id, opts, epoch = null) {
49
49
  };
50
50
  }
51
51
 
52
+ /**
53
+ * Drop the id's control buffer once the session is gone (close/forget/delete)
54
+ * so the map does not grow by one SharedArrayBuffer per session ever saved.
55
+ * Kept while a commit holds the lock: that write still serialises on this
56
+ * buffer. A later save for the same id simply allocates a fresh control.
57
+ */
58
+ export function releaseSessionWriteControl(id) {
59
+ const control = _writeControls.get(id);
60
+ if (!control || Atomics.load(control, 1) !== 0) return false;
61
+ return _writeControls.delete(id);
62
+ }
63
+
64
+ export function _hasSessionWriteControl(id) {
65
+ return _writeControls.has(id);
66
+ }
67
+
52
68
  export function cancelSessionWrites(id) {
53
69
  Atomics.add(writeControl(id), 0, 1);
54
70
  }
@@ -106,13 +106,25 @@ function agentStatusValues(row) {
106
106
  .filter((status, index, values) => Boolean(status) && values.indexOf(status) === index);
107
107
  }
108
108
 
109
+ /** The worker's lifecycle. `status` decides; `stage` is only the runtime phase
110
+ * copied in by the progress sweep, so mid-work it may read idle/done/error
111
+ * beside a worker that is still running. Only a row with no status falls
112
+ * back to its stage. */
113
+ function workerLifecycle(row) {
114
+ return cleanValue(row?.status) || cleanValue(row?.stage);
115
+ }
116
+
109
117
  function activeAgentWorker(row) {
110
- const statuses = agentStatusValues(row);
111
- return (
112
- statuses.length > 0 &&
113
- !statuses.some((status) => DEAD_AGENT_STATUS.test(status)) &&
114
- statuses.some((status) => LIVING_AGENT_STATUS.test(status))
115
- );
118
+ // A cancel in either field ends the work (bare `cancelling` is still work).
119
+ if (
120
+ agentStatusValues(row).some(
121
+ (status) => CANCELLED_AGENT_STATUS.test(status) || CANCEL_UNCONFIRMED_AGENT_STATUS.test(status)
122
+ )
123
+ ) {
124
+ return false;
125
+ }
126
+ const lifecycle = workerLifecycle(row);
127
+ return Boolean(lifecycle) && !DEAD_AGENT_STATUS.test(lifecycle) && LIVING_AGENT_STATUS.test(lifecycle);
116
128
  }
117
129
 
118
130
  function stampMs(value) {
@@ -287,7 +299,7 @@ const withinPoolWindow = (at, now) => at > 0 && now - at <= AGENT_POOL_HEARTBEAT
287
299
  * a working status whose own stamp is still inside the pool window. */
288
300
  function poolRowWorking(row, heartbeatMtimes, now) {
289
301
  if (withinPoolWindow(heartbeatMtimes.get(cleanValue(row?.sessionId)) || 0, now)) return true;
290
- if (!WORKING_AGENT_STATUS.test(cleanValue(row?.stage || row?.status))) return false;
302
+ if (!WORKING_AGENT_STATUS.test(workerLifecycle(row))) return false;
291
303
  return withinPoolWindow(Date.parse(cleanValue(row?.updatedAt)) || 0, now);
292
304
  }
293
305
 
@@ -307,7 +319,7 @@ function projectChildWorkerRow(row, { now, heartbeatMtimes }) {
307
319
  const session = readWorkerSessionHeader(sessionId);
308
320
  const declaredStatus = cleanValue(row.status) || 'running';
309
321
  const declaredStage = cleanValue(row.stage || row.status) || 'running';
310
- const declaredWorking = WORKING_AGENT_STATUS.test(declaredStatus) || WORKING_AGENT_STATUS.test(declaredStage);
322
+ const declaredWorking = WORKING_AGENT_STATUS.test(workerLifecycle(row) || 'running');
311
323
  const heartbeatFresh = withinPoolWindow(heartbeatMtimes.get(sessionId) || 0, now);
312
324
  const recentlyUpdated = withinPoolWindow(Date.parse(cleanValue(row.updatedAt)) || 0, now);
313
325
  const runtimePid = positiveNumber(row.runtimePid, 0);
@@ -414,7 +426,7 @@ function promoteHeartbeatSidecar(bySessionId, sessionId, heartbeatAt) {
414
426
  bySessionId.set(sessionId, sidecarWorkerRow(current, session, { ...identity, status: cancel.status }));
415
427
  return;
416
428
  }
417
- const currentStatus = cleanValue(current.stage || current.status);
429
+ const currentStatus = workerLifecycle(current);
418
430
  if (currentStatus && !WORKING_AGENT_STATUS.test(currentStatus)) return;
419
431
  // The sidecar is the live lease. Durable child sessions are intentionally
420
432
  // detached/closed while their external owner runs.
@@ -452,7 +464,7 @@ function projectLeadWorkerRow(row, { now, heartbeatMtimes }) {
452
464
  const heartbeatAt = heartbeatMtimes.get(sessionId) || 0;
453
465
  const heartbeatFresh = withinPoolWindow(heartbeatAt, now);
454
466
  const recentlyUpdated = withinPoolWindow(Date.parse(cleanValue(row.updatedAt)) || 0, now);
455
- const declaredStatus = cleanValue(row.stage || row.status) || 'idle';
467
+ const declaredStatus = workerLifecycle(row) || 'idle';
456
468
  const working = WORKING_AGENT_STATUS.test(declaredStatus) && (heartbeatFresh || recentlyUpdated);
457
469
  const reapAt = Date.parse(cleanValue(row.reapAt)) || 0;
458
470
  if (!heartbeatFresh && reapAt > 0 && now >= reapAt) return null;
@@ -21,6 +21,7 @@ import { dataDir } from './store-summary-locations.mjs';
21
21
  import {
22
22
  desktopSession,
23
23
  importKey,
24
+ inheritedFromSessionId,
24
25
  isStoredSessionId,
25
26
  positiveNumber,
26
27
  workspaceSummary,
@@ -188,6 +189,7 @@ export function normalizedRow(row, heartbeatAt = 0) {
188
189
  storageSize: positiveNumber(row.storageSize, 0),
189
190
  detachedReason: row.detachedReason || null,
190
191
  importKey: importKey(row),
192
+ inheritedFromSessionId: inheritedFromSessionId(row),
191
193
  };
192
194
  }
193
195
 
@@ -50,6 +50,11 @@ export function workspaceSummary(value) {
50
50
 
51
51
  /** Dedupe key of an imported conversation: a session's `importOrigin.key`,
52
52
  * or a summary row's projected `importKey`; null for ordinary sessions. */
53
+ export function inheritedFromSessionId(value) {
54
+ const id = typeof value?.inheritedFromSessionId === 'string' ? value.inheritedFromSessionId.trim() : '';
55
+ return id || null;
56
+ }
57
+
53
58
  export function importKey(value) {
54
59
  const key = typeof value?.importKey === 'string' ? value.importKey : value?.importOrigin?.key;
55
60
  return typeof key === 'string' && key.trim() ? key : null;
@@ -20,6 +20,7 @@ import { cleanSessionPreview, isSessionPreviewNoise, sessionMessageText } from '
20
20
  import { sessionVisibility } from './store-summary-visibility.mjs';
21
21
  import {
22
22
  importKey,
23
+ inheritedFromSessionId,
23
24
  isStoredSessionId,
24
25
  positiveNumber as _positiveNumber,
25
26
  workspaceSummary,
@@ -209,6 +210,7 @@ export function _sessionSummary(session) {
209
210
  // Imported-conversation identity, so "already imported" is answered
210
211
  // from summaries without loading sessions.
211
212
  importKey: importKey(session),
213
+ inheritedFromSessionId: inheritedFromSessionId(session),
212
214
  };
213
215
  }
214
216
 
@@ -258,6 +260,7 @@ function _normalizeSummaryRow(row) {
258
260
  storageSize: _positiveNumber(row.storageSize, 0),
259
261
  detachedReason: row.detachedReason || null,
260
262
  importKey: importKey(row),
263
+ inheritedFromSessionId: inheritedFromSessionId(row),
261
264
  };
262
265
  }
263
266
 
@@ -286,7 +286,9 @@ function mergeRestoredToolItems(items) {
286
286
  }
287
287
 
288
288
  function restoredMessageItemUpperBound(message) {
289
- const boundaries = Array.isArray(message?.meta?.sessionInheritances) ? message.meta.sessionInheritances.length : 0;
289
+ const boundaries =
290
+ (Array.isArray(message?.meta?.sessionInheritances) ? message.meta.sessionInheritances.length : 0) +
291
+ (Array.isArray(message?.meta?.modelSwitches) ? message.meta.modelSwitches.length : 0);
290
292
  if (message?.role === 'user') return 1 + boundaries;
291
293
  if (message?.role !== 'assistant') return boundaries;
292
294
  const calls = restoredMessageToolCalls(message);
@@ -377,6 +379,19 @@ function restoredUserTranscriptItems(message, nextId) {
377
379
  return [restoredNotificationToolItem(synthetic, text, message, nextId, /^(failed|error|killed|cancelled)$/i)];
378
380
  }
379
381
 
382
+ /** The transcript row of an in-place model switch (live and restored). */
383
+ export function modelSwitchItem(id, boundary) {
384
+ return {
385
+ kind: 'statusdone',
386
+ id,
387
+ status: 'model-switched',
388
+ label: 'Model switched',
389
+ provider: boundary.provider,
390
+ modelId: boundary.modelId,
391
+ at: boundary.at,
392
+ };
393
+ }
394
+
380
395
  function restoreTranscriptRange(messages, start, sessionId) {
381
396
  const items = [];
382
397
  const pendingToolCalls = new Map();
@@ -408,6 +423,10 @@ function restoreTranscriptRange(messages, start, sessionId) {
408
423
  at: boundary.at,
409
424
  });
410
425
  }
426
+ for (const boundary of Array.isArray(message?.meta?.modelSwitches) ? message.meta.modelSwitches : []) {
427
+ if (!boundary?.provider || !boundary?.modelId) continue;
428
+ items.push(modelSwitchItem(restoredId(), boundary));
429
+ }
411
430
  }
412
431
  return mergeRestoredToolItems(items);
413
432
  }
@@ -786,9 +786,9 @@ function guardShell(a) {
786
786
  }
787
787
  // Over-max is an unambiguous "wait as long as allowed"; clamp like the other
788
788
  // saturating integer args instead of failing the call.
789
- if (hasOwn(a, 'wait_ms') && Number.isInteger(a.wait_ms) && a.wait_ms > 120000) a.wait_ms = 120000;
789
+ if (hasOwn(a, 'wait_ms') && Number.isInteger(a.wait_ms) && a.wait_ms > 30000) a.wait_ms = 30000;
790
790
  if (hasOwn(a, 'wait_ms') && (!Number.isInteger(a.wait_ms) || a.wait_ms < 1000)) {
791
- return `Error: shell arg "wait_ms" must be an integer from 15000 to 120000, e.g. wait_ms:30000 (got ${JSON.stringify(a.wait_ms)})`;
791
+ return `Error: shell arg "wait_ms" must be an integer from 10000 to 30000, e.g. wait_ms:20000 (got ${JSON.stringify(a.wait_ms)})`;
792
792
  }
793
793
  if (!hasOwn(a, 'command')) {
794
794
  return 'Error: shell requires "command"';
@@ -1,12 +1,12 @@
1
1
  // Commands start in the foreground. Only work still running after the
2
- // 15 s coordination budget is promoted to a tracked background task.
2
+ // 10 s coordination budget is promoted to a tracked background task.
3
3
  // Short commands therefore complete in the original tool turn, while longer
4
4
  // work returns partial output plus task_id and finishes by notification.
5
- // 15 s because a command promoted moments before it ends costs a whole
6
- // follow-up request on `task wait`. Session logs (2026-09-30..10-05), the 260
7
- // promoted commands with a recorded duration: 108 had ended within 10 s, 144
8
- // within 15 s and only 152 within 20 s.
9
- export const DEFAULT_SHELL_AUTO_BACKGROUND_MS = 15_000;
5
+ // A per-call wait_ms may stretch the window for a command whose result should
6
+ // return in the same call, never past 30 s, so the conversation is never held
7
+ // open long; anything longer finishes by notification.
8
+ export const DEFAULT_SHELL_AUTO_BACKGROUND_MS = 10_000;
9
+ export const MAX_SHELL_WAIT_MS = 30_000;
10
10
 
11
11
  // JS timers (setTimeout) and PS WaitForExit(ms) are 32-bit: a delay above
12
12
  // 2^31-1 wraps to a tiny/negative value and fires immediately. Clamp the
@@ -17,17 +17,14 @@ const TIMER_MAX_MS = 2_147_483_647;
17
17
 
18
18
  // Main-agent blocking budget. A timeout is the command's total deadline, not
19
19
  // permission to hold the conversation open for that whole duration: after
20
- // 15 s a still-running command becomes a tracked background task and
20
+ // 10 s a still-running command becomes a tracked background task and
21
21
  // completion is pushed to the owner. MIXDOG_SHELL_AUTO_BACKGROUND_MS
22
22
  // overrides; an explicit 0 disables. Gated on backgroundOnTimeout so
23
23
  // disabled background tasks remain foreground.
24
- // A per-call wait_ms extends that default for the call, capped by the
25
- // foreground maximum; it never shortens it. A shorter window promotes a
26
- // command that would have returned its result in the starting call and spends
27
- // a whole follow-up request on `task wait`. Session logs (2026-09-30..10-05,
28
- // 3062 shell calls): 329 passed a wait_ms under the default (53% of one model
29
- // family's calls), 256 of those were promoted, and 108 of the 192 with a
30
- // recorded duration had finished inside the default window (10 s then).
24
+ // A per-call wait_ms extends that default for the call, capped by
25
+ // MAX_SHELL_WAIT_MS and the foreground maximum; it never shortens it. A
26
+ // shorter window promotes a command that would have returned its result in
27
+ // the starting call and spends a whole follow-up request on `task wait`.
31
28
  function autoBackgroundBudget(backgroundOnTimeout, timeout, waitMs, maxForegroundMs) {
32
29
  const raw = process.env.MIXDOG_SHELL_AUTO_BACKGROUND_MS;
33
30
  const parsed = Number(raw);
@@ -35,7 +32,7 @@ function autoBackgroundBudget(backgroundOnTimeout, timeout, waitMs, maxForegroun
35
32
  raw != null && String(raw).trim() !== '' && Number.isFinite(parsed) && parsed >= 0
36
33
  ? Math.floor(parsed)
37
34
  : DEFAULT_SHELL_AUTO_BACKGROUND_MS;
38
- if (waitMs > 0) defaultMs = Math.max(defaultMs, Math.min(waitMs, maxForegroundMs));
35
+ if (waitMs > 0) defaultMs = Math.max(defaultMs, Math.min(waitMs, MAX_SHELL_WAIT_MS, maxForegroundMs));
39
36
  if (!backgroundOnTimeout || defaultMs <= 0) return 0;
40
37
  return timeout > 0 ? Math.min(defaultMs, timeout) : defaultMs;
41
38
  }
@@ -146,7 +146,7 @@ export const BUILTIN_TOOLS = [
146
146
  openWorldHint: true,
147
147
  compressible: true,
148
148
  },
149
- description: `Run programs, builds, tests and computation. Never use shell commands or scripts for work covered by dedicated tools: cat/head/tail→read, ls→list, find→glob, filename lookup→find, grep/rg→grep, code structure→code_graph, file edits/writes (sed/awk/redirection)→edit/apply_patch, Git→git when that tool is on the surface. Tool names are not shell commands. ${_shellBackgroundDisabled ? 'Commands run in the foreground until completion.' : 'After a 15s foreground window (not a timeout; wait_ms extends it up to 120s for one call, e.g. a test suite), unfinished work continues under task_id; use task wait, not read polling. If no execution slot frees within the admission wait, the command is queued as a background task with a task_id. Dev servers/watchers: pass timeout_ms or stop them with task cancel when done.'}`,
149
+ description: `Run programs, builds, tests and computation. Never use shell commands or scripts for work covered by dedicated tools: cat/head/tail→read, ls→list, find→glob, filename lookup→find, grep/rg→grep, code structure→code_graph, file edits/writes (sed/awk/redirection)→edit/apply_patch, Git→git when that tool is on the surface. Tool names are not shell commands. ${_shellBackgroundDisabled ? 'Commands run in the foreground until completion.' : 'After a 10s foreground window (not a timeout; omit wait_ms by default, set it up to 30s only for a command you expect to finish in 10–30s, e.g. one test file), unfinished work continues under task_id; use task wait, not read polling. If no execution slot frees within the admission wait, the command is queued as a background task with a task_id. Dev servers/watchers: pass timeout_ms or stop them with task cancel when done.'}`,
150
150
  inputSchema: {
151
151
  type: 'object',
152
152
  properties: {
@@ -155,14 +155,14 @@ export const BUILTIN_TOOLS = [
155
155
  type: 'integer',
156
156
  minimum: 0,
157
157
  description:
158
- 'Hard kill deadline in ms (omit or 0 = none), separate from the 15s foreground window; set one for throwaway probes so a slow check fails fast.',
158
+ 'Hard kill deadline in ms (omit or 0 = none), separate from the 10s foreground window; set one for throwaway probes so a slow check fails fast.',
159
159
  },
160
160
  wait_ms: {
161
161
  type: 'integer',
162
- minimum: 15000,
163
- maximum: 120000,
162
+ minimum: 10000,
163
+ maximum: 30000,
164
164
  description:
165
- 'Foreground wait in ms before unfinished work continues as a background task (default 15000); raise it for a command that should return its result in this call.',
165
+ 'Foreground wait in ms before unfinished work continues as a background task (default 10000). Omit it by default. Set it only when you expect the command to finish within 10–30s (e.g. one test file or a small build step); never for work expected to take longer, which finishes by notification.',
166
166
  },
167
167
  },
168
168
  required: ['command'],