mixdog 0.9.147 → 0.9.148

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 (255) hide show
  1. package/package.json +23 -3
  2. package/scripts/build-voice-runtime-unix.sh +101 -0
  3. package/scripts/embedding-quality-probe.mjs +96 -0
  4. package/scripts/generate-voice-runtime-manifest.mjs +300 -0
  5. package/scripts/generate-voice-runtime-manifest.test.mjs +135 -0
  6. package/scripts/native-binary-arch.mjs +30 -13
  7. package/scripts/native-binary-arch.test.mjs +40 -4
  8. package/scripts/plan-voice-runtime-release.mjs +73 -0
  9. package/scripts/prepare-native-assets.mjs +50 -9
  10. package/scripts/prune-embedding-runtime.mjs +3 -3
  11. package/scripts/sync-voice-runtime-manifest.mjs +46 -0
  12. package/scripts/verify-release-assets.mjs +106 -6
  13. package/scripts/verify-voice-runtime-release.mjs +89 -0
  14. package/scripts/voice-runtime-config.json +52 -0
  15. package/src/app.mjs +42 -5
  16. package/src/headless-exec.mjs +58 -32
  17. package/src/headless-exec.test.mjs +74 -13
  18. package/src/rules/lead/01-general.md +2 -0
  19. package/src/rules/shared/10-tool-workflow.md +7 -4
  20. package/src/rules/shared/30-exploration.md +2 -1
  21. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +28 -9
  22. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +6 -1
  23. package/src/runtime/agent/orchestrator/config.mjs +53 -18
  24. package/src/runtime/agent/orchestrator/mcp/cancellation.test.mjs +139 -0
  25. package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +34 -5
  26. package/src/runtime/agent/orchestrator/mcp/client.mjs +94 -10
  27. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +5 -0
  28. package/src/runtime/agent/orchestrator/providers/anthropic-fast-mode.mjs +98 -0
  29. package/src/runtime/agent/orchestrator/providers/anthropic-fast-mode.test.mjs +90 -0
  30. package/src/runtime/agent/orchestrator/providers/anthropic-leaked-toolcall.mjs +67 -16
  31. package/src/runtime/agent/orchestrator/providers/anthropic-messages.mjs +4 -82
  32. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +0 -5
  33. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +62 -121
  34. package/src/runtime/agent/orchestrator/providers/anthropic-server-fallback.mjs +48 -0
  35. package/src/runtime/agent/orchestrator/providers/anthropic-server-fallback.test.mjs +215 -0
  36. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +44 -48
  37. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +54 -31
  38. package/src/runtime/agent/orchestrator/providers/antigravity-oauth.mjs +56 -2
  39. package/src/runtime/agent/orchestrator/providers/cursor-auth.mjs +34 -2
  40. package/src/runtime/agent/orchestrator/providers/cursor-wire.mjs +61 -1
  41. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +6 -1
  42. package/src/runtime/agent/orchestrator/providers/gemini.mjs +61 -15
  43. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +120 -1
  44. package/src/runtime/agent/orchestrator/providers/lib/env-utils.mjs +9 -0
  45. package/src/runtime/agent/orchestrator/providers/lib/stream-outcome.mjs +0 -3
  46. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +187 -84
  47. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +36 -36
  48. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +16 -6
  49. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +50 -213
  50. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +135 -126
  51. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +17 -11
  52. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +20 -7
  53. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +11 -0
  54. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +132 -42
  55. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +2 -2
  56. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +28 -10
  57. package/src/runtime/agent/orchestrator/providers/registry.mjs +122 -95
  58. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +58 -3
  59. package/src/runtime/agent/orchestrator/providers/tool-stream-state.mjs +4 -22
  60. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +7 -14
  61. package/src/runtime/agent/orchestrator/session/compact-policy.test.mjs +128 -0
  62. package/src/runtime/agent/orchestrator/session/context-compaction-policy.mjs +5 -0
  63. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +27 -48
  64. package/src/runtime/agent/orchestrator/session/loop/steering.mjs +19 -14
  65. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +9 -43
  66. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +5 -1
  67. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +15 -2
  68. package/src/runtime/agent/orchestrator/session/manager/pending-messages-persistence.test.mjs +325 -0
  69. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +599 -195
  70. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +22 -0
  71. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +9 -2
  72. package/src/runtime/agent/orchestrator/session/manager.mjs +3 -0
  73. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +26 -16
  74. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +23 -11
  75. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +118 -3
  76. package/src/runtime/agent/orchestrator/session/store-summary-reader.test.mjs +332 -0
  77. package/src/runtime/agent/orchestrator/session/store.mjs +38 -1
  78. package/src/runtime/agent/orchestrator/tools/builtin/advisory-lock.mjs +27 -6
  79. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +12 -20
  80. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +291 -128
  81. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +45 -8
  82. package/src/runtime/agent/orchestrator/tools/builtin/edit-encoding-eol.test.mjs +224 -0
  83. package/src/runtime/agent/orchestrator/tools/builtin/edit-sequential-occupation.test.mjs +53 -3
  84. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +255 -25
  85. package/src/runtime/agent/orchestrator/tools/builtin/find-search-budget.test.mjs +87 -0
  86. package/src/runtime/agent/orchestrator/tools/builtin/git-command-policy.mjs +1 -1
  87. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +132 -9
  88. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.test.mjs +10 -0
  89. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-pattern-fanout.mjs +29 -3
  90. package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-job-records.mjs +1 -1
  91. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +178 -29
  92. package/src/runtime/agent/orchestrator/tools/builtin/local-search-telemetry.mjs +4 -1
  93. package/src/runtime/agent/orchestrator/tools/builtin/native-search-client.mjs +32 -5
  94. package/src/runtime/agent/orchestrator/tools/builtin/native-search-health.test.mjs +5 -2
  95. package/src/runtime/agent/orchestrator/tools/builtin/native-search-runner.mjs +10 -1
  96. package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +38 -10
  97. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +12 -0
  98. package/src/runtime/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +31 -1
  99. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +11 -1
  100. package/src/runtime/agent/orchestrator/tools/builtin/read-windows.mjs +22 -4
  101. package/src/runtime/agent/orchestrator/tools/builtin/runtime-capabilities.mjs +6 -8
  102. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +25 -24
  103. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +152 -133
  104. package/src/runtime/agent/orchestrator/tools/builtin/task-tool.mjs +114 -42
  105. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +129 -15
  106. package/src/runtime/agent/orchestrator/tools/code-graph/loose-file-target.test.mjs +72 -0
  107. package/src/runtime/agent/orchestrator/tools/code-graph/project-root.mjs +7 -8
  108. package/src/runtime/agent/orchestrator/tools/code-graph/search-references.mjs +4 -4
  109. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +201 -19
  110. package/src/runtime/agent/orchestrator/tools/code-graph/span.mjs +26 -3
  111. package/src/runtime/agent/orchestrator/tools/code-graph/symbol-index.mjs +10 -1
  112. package/src/runtime/agent/orchestrator/tools/code-graph/symbol-search-scope.test.mjs +55 -0
  113. package/src/runtime/agent/orchestrator/tools/code-graph/text-mask.mjs +115 -25
  114. package/src/runtime/agent/orchestrator/tools/code-graph/text-span-mask.test.mjs +268 -0
  115. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +3 -3
  116. package/src/runtime/agent/orchestrator/tools/lib/native-spawn-client.mjs +296 -46
  117. package/src/runtime/agent/orchestrator/tools/lib/native-spawn-client.test.mjs +104 -0
  118. package/src/runtime/agent/orchestrator/tools/lib/shell-descendants.mjs +136 -0
  119. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +127 -38
  120. package/src/runtime/agent/orchestrator/tools/patch/engine-contract-gate.test.mjs +661 -0
  121. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +350 -9
  122. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +418 -44
  123. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +27 -3
  124. package/src/runtime/agent/orchestrator/tools/patch/patch-codec-eol.test.mjs +552 -0
  125. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +65 -15
  126. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
  127. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +284 -109
  128. package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +51 -21
  129. package/src/runtime/agent/orchestrator/tools/shell-exec-policy.mjs +44 -27
  130. package/src/runtime/agent/orchestrator/tools/shell-policy.mjs +6 -1
  131. package/src/runtime/agent/orchestrator/tools/spawn-manifest.json +11 -11
  132. package/src/runtime/attachments/store.mjs +11 -3
  133. package/src/runtime/channels/data/voice-runtime-manifest.json +46 -70
  134. package/src/runtime/channels/lib/event-queue.mjs +51 -41
  135. package/src/runtime/channels/lib/scheduler.mjs +23 -28
  136. package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +6 -17
  137. package/src/runtime/channels/lib/voice-runtime-fetcher.test.mjs +17 -0
  138. package/src/runtime/channels/lib/webhook.mjs +39 -20
  139. package/src/runtime/channels/lib/whisper-server.mjs +46 -1
  140. package/src/runtime/channels/lib/worker-main.mjs +3 -1
  141. package/src/runtime/media/adapters/gemini-video.mjs +20 -4
  142. package/src/runtime/media/jobs.mjs +13 -0
  143. package/src/runtime/media/renditions.mjs +7 -1
  144. package/src/runtime/media/store.mjs +42 -22
  145. package/src/runtime/memory/index.mjs +4 -9
  146. package/src/runtime/memory/lib/agent-ipc.mjs +1 -6
  147. package/src/runtime/memory/lib/archive-security.test.mjs +21 -1
  148. package/src/runtime/memory/lib/core-memory-store.mjs +28 -50
  149. package/src/runtime/memory/lib/embedding-model-config.mjs +37 -1
  150. package/src/runtime/memory/lib/embedding-model-config.test.mjs +33 -0
  151. package/src/runtime/memory/lib/embedding-provider.mjs +34 -12
  152. package/src/runtime/memory/lib/embedding-worker.mjs +37 -5
  153. package/src/runtime/memory/lib/ko-morph.mjs +46 -15
  154. package/src/runtime/memory/lib/memory-action-handlers.mjs +18 -1
  155. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -18
  156. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +1 -18
  157. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +98 -45
  158. package/src/runtime/memory/lib/memory-cycle2-shared.mjs +71 -0
  159. package/src/runtime/memory/lib/memory-cycle2-shared.test.mjs +78 -0
  160. package/src/runtime/memory/lib/memory-cycle2.mjs +37 -3
  161. package/src/runtime/memory/lib/memory-cycle3.mjs +1 -1
  162. package/src/runtime/memory/lib/memory-embedding-migration.test.mjs +50 -0
  163. package/src/runtime/memory/lib/memory-recall-store.mjs +9 -24
  164. package/src/runtime/memory/lib/memory.mjs +50 -12
  165. package/src/runtime/memory/lib/pg/process.mjs +1 -10
  166. package/src/runtime/memory/lib/pg/supervisor.mjs +1 -9
  167. package/src/runtime/memory/lib/query-handlers.mjs +2 -4
  168. package/src/runtime/memory/lib/session-ingest-runtime.mjs +2 -2
  169. package/src/runtime/shared/background-tasks.mjs +15 -0
  170. package/src/runtime/shared/background-tasks.test.mjs +78 -0
  171. package/src/runtime/shared/child-guardian.mjs +2 -10
  172. package/src/runtime/shared/pid-liveness.mjs +27 -0
  173. package/src/runtime/shared/runtime-root.mjs +1 -11
  174. package/src/runtime/shared/service-discovery.mjs +1 -17
  175. package/src/runtime/shared/singleton-owner.mjs +1 -11
  176. package/src/runtime/shared/staged-update.mjs +2 -12
  177. package/src/runtime/shared/tool-status.mjs +5 -1
  178. package/src/runtime/shared/tool-surface.mjs +15 -7
  179. package/src/runtime/shared/turn-snapshot-store.mjs +168 -0
  180. package/src/runtime/shared/turn-snapshot.mjs +137 -13
  181. package/src/runtime/shared/turn-worktree-snapshot.mjs +110 -18
  182. package/src/runtime/shared/webhook-session-run.mjs +16 -2
  183. package/src/runtime/web-search/lib/http-fetch.mjs +34 -4
  184. package/src/runtime/web-search/lib/state.mjs +15 -15
  185. package/src/runtime/web-search/lib/web-tools.mjs +46 -5
  186. package/src/session-runtime/context-status.mjs +4 -12
  187. package/src/session-runtime/runtime-core.mjs +20 -1
  188. package/src/standalone/agent-dispatch-broker.mjs +33 -2
  189. package/src/standalone/agent-tool/job-task-reconcile.mjs +78 -0
  190. package/src/standalone/agent-tool/notify.mjs +3 -1
  191. package/src/standalone/agent-tool/spawn-flow.mjs +48 -66
  192. package/src/standalone/agent-tool/tag-registry.mjs +116 -33
  193. package/src/standalone/agent-tool/tool-def.mjs +2 -2
  194. package/src/standalone/agent-tool/worker-index.mjs +8 -11
  195. package/src/standalone/agent-tool/worker-rows.mjs +4 -1
  196. package/src/standalone/agent-tool.mjs +20 -55
  197. package/src/standalone/agent-watchdog-registry.mjs +28 -4
  198. package/src/standalone/channel-client.mjs +47 -15
  199. package/src/standalone/channel-transport.mjs +44 -25
  200. package/src/standalone/daemon.mjs +31 -2
  201. package/src/standalone/hook-bus/config.mjs +25 -9
  202. package/src/standalone/hook-bus/handlers.mjs +16 -52
  203. package/src/standalone/hook-bus.mjs +29 -23
  204. package/src/standalone/memory-runtime-proxy.mjs +1 -16
  205. package/src/standalone/session-client.mjs +86 -23
  206. package/src/standalone/session-protocol.mjs +0 -2
  207. package/src/standalone/session-runtime-host-health.test.mjs +5 -4
  208. package/src/standalone/session-runtime-host.mjs +101 -6
  209. package/src/standalone/session-runtime-record.mjs +1 -5
  210. package/src/standalone/session-runtime-worker.mjs +52 -57
  211. package/src/standalone/session-service.mjs +4 -1
  212. package/src/standalone/session-transport.mjs +56 -25
  213. package/src/tui/App.jsx +40 -49
  214. package/src/tui/app/app-view.jsx +14 -21
  215. package/src/tui/app/core-memory-picker.mjs +29 -16
  216. package/src/tui/app/create-app-pickers.mjs +15 -36
  217. package/src/tui/app/extension-pickers.mjs +60 -49
  218. package/src/tui/app/maintenance-pickers.mjs +100 -57
  219. package/src/tui/app/message-selector.mjs +6 -5
  220. package/src/tui/app/model-picker.mjs +30 -13
  221. package/src/tui/app/onboarding-steps.mjs +21 -12
  222. package/src/tui/app/panel-epoch.mjs +56 -0
  223. package/src/tui/app/panel-epoch.test.mjs +1009 -0
  224. package/src/tui/app/panel-surface.mjs +98 -0
  225. package/src/tui/app/project-picker.mjs +30 -29
  226. package/src/tui/app/prompt-submit.mjs +192 -97
  227. package/src/tui/app/prompt-submit.test.mjs +206 -0
  228. package/src/tui/app/provider-setup-picker.mjs +74 -43
  229. package/src/tui/app/resume-picker.mjs +11 -8
  230. package/src/tui/app/route-pickers.mjs +45 -28
  231. package/src/tui/app/settings-picker.mjs +69 -18
  232. package/src/tui/app/shell-layout.mjs +1 -3
  233. package/src/tui/app/slash-commands.mjs +0 -2
  234. package/src/tui/app/slash-dispatch.mjs +4 -2
  235. package/src/tui/app/text-entry-policy.mjs +25 -0
  236. package/src/tui/app/theme-effort-pickers.mjs +11 -16
  237. package/src/tui/app/usage-context-panels.mjs +21 -25
  238. package/src/tui/app/use-global-key-input.mjs +3 -2
  239. package/src/tui/app/use-prompt-draft-flow.mjs +6 -18
  240. package/src/tui/app/use-prompt-handlers.mjs +3 -2
  241. package/src/tui/app/use-welcome-prompt-hint.mjs +17 -3
  242. package/src/tui/components/Picker.jsx +1 -1
  243. package/src/tui/components/TextEntryPanel.jsx +7 -2
  244. package/src/tui/dist/index.mjs +652 -561
  245. package/src/tui/lib/voice-setup.mjs +1 -2
  246. package/src/tui/session/agent-job-feed.mjs +73 -11
  247. package/src/tui/session/session-api-ext.mjs +5 -6
  248. package/src/tui/session/session-api.mjs +1 -0
  249. package/src/tui/session/tool-approval.mjs +15 -5
  250. package/src/tui/session/tui-steering-persist.mjs +33 -19
  251. package/src/tui/session/tui-steering-persist.test.mjs +104 -0
  252. package/src/tui/session-local.mjs +0 -22
  253. package/scripts/.tmp-coverage-report.mjs +0 -96
  254. package/src/runtime/agent/orchestrator/tools/builtin/shell-monitor.mjs +0 -57
  255. package/src/runtime/agent/orchestrator/tools/builtin/shell-monitor.test.mjs +0 -57
@@ -8,7 +8,7 @@ import { randomBytes } from 'crypto';
8
8
  import {
9
9
  PROVIDER_FIRST_BYTE_TIMEOUT_MS,
10
10
  PROVIDER_SSE_IDLE_WATCHDOG_ENABLED,
11
- PROVIDER_SEMANTIC_IDLE_TIMEOUT_MS,
11
+ PROVIDER_SSE_IDLE_TIMEOUT_MS,
12
12
  streamStalledError,
13
13
  } from '../stall-policy.mjs';
14
14
  import {
@@ -25,6 +25,10 @@ import {
25
25
  } from './lib/anthropic-native-blocks.mjs';
26
26
  import { parseProviderJsonBatch } from './stream-json-pool.mjs';
27
27
  import { stampStreamOutcome, STREAM_TRANSPORTS, STREAM_OUTCOME_VERSION } from './lib/stream-outcome.mjs';
28
+ import {
29
+ anthropicFallbackProviderMetadata,
30
+ parseAnthropicFallbackBlock,
31
+ } from './anthropic-server-fallback.mjs';
28
32
 
29
33
  /** Bounded mid-stream SSE retries (transient stream loss); shared with anthropic.mjs.
30
34
  * Sourced from the single shared retry-budget table (MIDSTREAM_RETRY_POLICY.sse). */
@@ -132,20 +136,23 @@ function cloneNativeBlock(block) {
132
136
  }
133
137
 
134
138
  export async function parseSSEStream(response, signal, abortStream, onStreamDelta, onToolCall, state, onTextDelta, knownToolNames) {
135
- // onStreamDelta is a semantic-progress callback. HTTP headers and raw SSE
136
- // bytes prove transport health but must not refresh semantic watchdogs.
139
+ // Anthropic/Claude parity: every received SSE byte proves transport
140
+ // activity, including comment and named ping keepalives. Content kinds
141
+ // remain distinct on onStreamDelta so TTFT and visible-progress accounting
142
+ // do not mistake transport heartbeats for model output.
137
143
  const reader = response.body.getReader();
138
144
  const decoder = new TextDecoder();
139
- // SEMANTIC idle window: reset only by real model events (message/content/
140
- // tool deltas), NOT by raw keepalive bytes. A ping-only wedge therefore
141
- // trips this within the window instead of hanging until the 30-min agent
142
- // watchdog. See resetIdleTimer + the per-event reset in the loop below.
143
- // state.semanticIdleTimeoutMs is a test/override seam (same shape as
144
- // firstMessageTimeoutMs); production uses the shared env-backed default.
145
- const SSE_IDLE_TIMEOUT_MS = Number.isFinite(Number(state?.semanticIdleTimeoutMs))
146
- && Number(state.semanticIdleTimeoutMs) > 0
147
- ? Number(state.semanticIdleTimeoutMs)
148
- : PROVIDER_SEMANTIC_IDLE_TIMEOUT_MS;
145
+ // Transport-idle window. The legacy semanticIdleTimeoutMs seam remains as
146
+ // a fallback for existing tests/callers, but production uses the shared
147
+ // byte/event inactivity policy.
148
+ const configuredIdleTimeoutMs = state?.transportIdleTimeoutMs ?? state?.semanticIdleTimeoutMs;
149
+ const SSE_IDLE_TIMEOUT_MS = Number.isFinite(Number(configuredIdleTimeoutMs))
150
+ && Number(configuredIdleTimeoutMs) > 0
151
+ ? Number(configuredIdleTimeoutMs)
152
+ : PROVIDER_SSE_IDLE_TIMEOUT_MS;
153
+ const idleWatchdogEnabled = typeof state?.transportIdleWatchdogEnabled === 'boolean'
154
+ ? state.transportIdleWatchdogEnabled
155
+ : PROVIDER_SSE_IDLE_WATCHDOG_ENABLED;
149
156
  const SSE_FIRST_MESSAGE_TIMEOUT_MS = Number.isFinite(Number(state?.firstMessageTimeoutMs))
150
157
  && Number(state.firstMessageTimeoutMs) > 0
151
158
  ? Number(state.firstMessageTimeoutMs)
@@ -215,6 +222,7 @@ export async function parseSSEStream(response, signal, abortStream, onStreamDelt
215
222
  let usage = { inputTokens: 0, outputTokens: 0, cachedTokens: 0, cacheWriteTokens: 0, raw: null };
216
223
  let stopReason = null;
217
224
  let stopDetails;
225
+ const fallbackEvents = [];
218
226
  let buffer = '';
219
227
  let idleTimedOut = false;
220
228
  let firstMessageTimedOut = false;
@@ -389,10 +397,7 @@ export async function parseSSEStream(response, signal, abortStream, onStreamDelt
389
397
  };
390
398
 
391
399
  const resetIdleTimer = () => {
392
- // OFF by default. When disabled the
393
- // idle timer never arms, so the stream is never killed on inactivity;
394
- // the agent stall watchdog (600s) remains the dead-stream backstop.
395
- if (!PROVIDER_SSE_IDLE_WATCHDOG_ENABLED) return;
400
+ if (!idleWatchdogEnabled) return;
396
401
  if (idleTimer) clearTimeout(idleTimer);
397
402
  idleTimer = setTimeout(() => {
398
403
  idleTimedOut = true;
@@ -413,9 +418,8 @@ export async function parseSSEStream(response, signal, abortStream, onStreamDelt
413
418
  const e = _attachStallPartial(streamStalledError('Anthropic OAuth SSE', SSE_IDLE_TIMEOUT_MS, { emittedToolCall: !!state?.emittedToolCall }));
414
419
  const r = idleReject; idleReject = null; r(e);
415
420
  }
416
- // Shared provider policy: short SEMANTIC-event inactivity catches the
417
- // ping-only wedge where SSE starts, emits some deltas, then goes silent
418
- // while `:ping` keepalives keep the transport socket warm.
421
+ // Only actual transport silence trips this. Anthropic keepalives prove
422
+ // that the generation connection is still alive.
419
423
  }, SSE_IDLE_TIMEOUT_MS);
420
424
  try { idleTimer.unref?.(); } catch {}
421
425
  };
@@ -438,11 +442,11 @@ export async function parseSSEStream(response, signal, abortStream, onStreamDelt
438
442
  }
439
443
  signal.addEventListener('abort', onAbort, { once: true });
440
444
  }
441
- // Part A / reviewer fix: do NOT arm the SEMANTIC idle timer before the
445
+ // Do not arm the transport-idle timer before the
442
446
  // stream has produced its first event. A slow first response is governed
443
447
  // by armFirstMessageTimer() (first-byte window) alone; arming the
444
- // semantic idle here could let it win and mis-abort a legitimately slow
445
- // first response as a stall. The semantic idle is first armed at
448
+ // transport idle here could let it win and mis-abort a legitimately slow
449
+ // first response as a stall. The transport idle is first armed at
446
450
  // `message_start` (see below), so it only ever guards MID-stream silence.
447
451
  armFirstMessageTimer();
448
452
  streamLoop: while (true) {
@@ -471,18 +475,17 @@ export async function parseSSEStream(response, signal, abortStream, onStreamDelt
471
475
  }
472
476
  const { done, value } = chunk;
473
477
  if (done) break;
478
+ if (state?.sawMessageStart) resetIdleTimer();
479
+ try { onStreamDelta?.('transport'); } catch {}
474
480
  buffer += decoder.decode(value, { stream: true });
475
481
  const lines = buffer.split('\n');
476
482
  buffer = lines.pop() || '';
477
483
 
478
484
  for (const line of lines) {
479
485
  if (line.startsWith(':')) {
480
- // SSE comment frame (Anthropic `:ping` keepalive). Keep it
481
- // at transport level only: comments must not refresh the
482
- // agent's semantic progress timestamp, or a ping-only 200
483
- // can look alive forever without message_start/content.
484
- // Crucially it also does NOT reset the SEMANTIC idle timer
485
- // below — a ping-only wedge must trip the idle abort.
486
+ // SSE comment frame (Anthropic `:ping` keepalive). The raw
487
+ // chunk already refreshed transport activity above; it is
488
+ // intentionally not counted as visible model progress.
486
489
  continue;
487
490
  }
488
491
  // Blank lines are SSE record separators — emitted after EVERY
@@ -504,18 +507,6 @@ export async function parseSSEStream(response, signal, abortStream, onStreamDelt
504
507
  // cancellation after this frame is relayed.
505
508
  const [event] = await parseProviderJsonBatch([data]);
506
509
 
507
- // SEMANTIC idle reset (Part A): reset the idle timer ONLY for
508
- // real progress events, NOT for Anthropic keepalives. Anthropic
509
- // sends pings as a NAMED event (`event: ping` /
510
- // `data: {"type":"ping"}`), not just `:` comment frames, so a
511
- // named ping must be excluded here or a ping-only wedge keeps
512
- // the timer alive forever. Everything that is not a ping is a
513
- // genuine server event (message_start/content/tool/thinking
514
- // deltas, message_delta/stop, errors) and counts as progress.
515
- if (currentEvent !== 'ping' && event?.type !== 'ping') {
516
- resetIdleTimer();
517
- }
518
-
519
510
  if (currentEvent === 'error' || event?.type === 'error' || event?.error) {
520
511
  throw _anthropicSseError(event);
521
512
  }
@@ -523,12 +514,9 @@ export async function parseSSEStream(response, signal, abortStream, onStreamDelt
523
514
  if (event.type === 'message_start' && event.message) {
524
515
  clearFirstMessageTimer();
525
516
  if (state) state.sawMessageStart = true;
526
- // The first protocol event proves the response transport
527
- // is live. Report it only here (never for raw bytes,
528
- // comments, or ping events), then separately report the
529
- // semantic message boundary. Content kinds remain
530
- // reasoning/text/tool-specific below.
531
- try { onStreamDelta?.('transport'); } catch {}
517
+ resetIdleTimer();
518
+ // Transport activity was already reported for the raw
519
+ // chunk; this reports the semantic message boundary.
532
520
  try { onStreamDelta?.('semantic'); } catch {}
533
521
  if (event.message.model) model = event.message.model;
534
522
  if (event.message.usage) {
@@ -541,6 +529,13 @@ export async function parseSSEStream(response, signal, abortStream, onStreamDelt
541
529
 
542
530
  if (event.type === 'content_block_start') {
543
531
  const block = event.content_block;
532
+ const fallback = parseAnthropicFallbackBlock(block);
533
+ if (fallback) {
534
+ fallbackEvents.push(fallback);
535
+ model = fallback.fallbackModel;
536
+ contentBlockTypes.add('fallback');
537
+ try { onStreamDelta?.('semantic'); } catch {}
538
+ }
544
539
  if (block?.type === 'tool_use') {
545
540
  if (state) state.partialToolCall = true;
546
541
  pendingToolInputs.set(event.index, {
@@ -757,7 +752,7 @@ export async function parseSSEStream(response, signal, abortStream, onStreamDelt
757
752
  // An invalid-args marker never fingerprint-collides with
758
753
  // a real recovered call, so malformed native calls still
759
754
  // dispatch (the marker path is unaffected).
760
- if (_toolDedupe.shouldDispatch(call.name, call.arguments)) {
755
+ if (_toolDedupe.shouldDispatch(call.name, call.arguments, call.id)) {
761
756
  toolCalls.push(call);
762
757
  // Ordered replay copy of the dispatched call —
763
758
  // skipped/deduped calls stay out so a replayed
@@ -947,6 +942,7 @@ export async function parseSSEStream(response, signal, abortStream, onStreamDelt
947
942
  // assistantBlocks branch). Absent for every ordinary turn, which
948
943
  // keeps the existing text/thinking/tool_use lowering untouched.
949
944
  assistantBlocks: _orderedAssistantBlocks(),
945
+ providerMetadata: anthropicFallbackProviderMetadata(fallbackEvents),
950
946
  };
951
947
  } finally {
952
948
  if (idleTimer) clearTimeout(idleTimer);
@@ -8,7 +8,8 @@ import {
8
8
  anthropicMaxAttempts,
9
9
  anthropicRequestTimeoutMs,
10
10
  classifyError,
11
- createStallRetryBudget,
11
+ markProviderRecoveryExhausted,
12
+ resolveStallRetryBudget,
12
13
  midstreamBackoffFor,
13
14
  sleepWithAbort,
14
15
  STREAM_STALL_RETRY_BUDGET_MS,
@@ -31,6 +32,8 @@ import {
31
32
  stampAnthropicStreamOutcome,
32
33
  } from './anthropic-sse.mjs';
33
34
  import { buildAnthropicBetaHeaders, supportsAnthropicFastMode } from './anthropic-betas.mjs';
35
+ import { applyAnthropicServerFallback } from './anthropic-server-fallback.mjs';
36
+ import { fastModeAvailable, noteFastModeCapacityError } from './anthropic-fast-mode.mjs';
34
37
  import {
35
38
  applyAnthropicEffortToBody,
36
39
  effortValuesForModel,
@@ -52,6 +55,7 @@ import {
52
55
  requestAnthropicTools as sharedRequestAnthropicTools,
53
56
  normalizeAnthropicNonStreamingResponse,
54
57
  resolveAnthropicCacheTtls as resolveCacheTtls,
58
+ resolveAnthropicMessageCacheSlots,
55
59
  sanitizeAnthropicInputSchema,
56
60
  toAnthropicToolChoice,
57
61
  } from './lib/anthropic-request-utils.mjs';
@@ -138,30 +142,16 @@ export class AnthropicProvider {
138
142
  // (cacheTier:'tier3') at ttls.tier3 — each its own system content block.
139
143
  const systemBlocks = buildSystemBlocks(systemMsgs, ttls.system, ttls.tier3);
140
144
 
141
- // 4-BP budget: aligned with anthropic-oauth. tools BP is dropped —
142
- // system BP covers the tools prefix via Anthropic prefix semantics
143
- // (order: tools → system → messages). That frees 1 slot for
144
- // messages-tail.
145
- const toolsBpUsed = 0;
146
- const systemBpUsed = systemBlocks.filter(b => b.cache_control).length;
147
- const usedSlots = toolsBpUsed + systemBpUsed;
148
- // Env override for BP strategy. ANTHROPIC_MSG_SLOTS=0 disables
149
- // message caching entirely. Any value >=1 first marks the previous
150
- // user text turn; a second free slot marks the tail.
151
- const msgSlotsCap = Number.parseInt(process.env.ANTHROPIC_MSG_SLOTS, 10);
152
- const defaultMsgSlots = Math.max(0, 4 - usedSlots);
153
- const msgSlots = ttls.messages
154
- ? (Number.isFinite(msgSlotsCap) && msgSlotsCap >= 0 ? Math.min(msgSlotsCap, defaultMsgSlots) : defaultMsgSlots)
155
- : 0;
145
+ // Message-tail cache budget (4-BP layout + ANTHROPIC_MSG_SLOTS) is
146
+ // shared with anthropic-oauth — see resolveAnthropicMessageCacheSlots.
147
+ const messageCacheSlots = resolveAnthropicMessageCacheSlots(systemBlocks, ttls);
156
148
  // Build → sanitize (once, inside toAnthropicMessages) → mark. Markers
157
149
  // are applied to the FINAL sanitized array by invariant, so block
158
150
  // drops / inserts / reorders performed by the sanitizer can never move
159
151
  // or delete a marked block. NEVER sanitize again after this.
160
- // msgSlots === 0 → message-tail disabled.
161
- const tailTtl = msgSlots > 0 ? ttls.messages : null;
162
152
  const anthropicMessages = applyAnthropicCacheMarkers(
163
153
  toAnthropicMessages(chatMsgs),
164
- { messageTtl: tailTtl, messageSlots: msgSlots },
154
+ messageCacheSlots,
165
155
  );
166
156
 
167
157
  const params = {
@@ -170,6 +160,9 @@ export class AnthropicProvider {
170
160
  system: systemBlocks.length ? systemBlocks : undefined,
171
161
  messages: anthropicMessages,
172
162
  };
163
+ applyAnthropicServerFallback(params, useModel, {
164
+ enabled: this.config?.disableBetaHeaders !== true && opts.serverFallback !== false,
165
+ });
173
166
  const requestTools = requestAnthropicTools(tools, chatMsgs, opts);
174
167
  if (requestTools.length) {
175
168
  // No cache_control on tools — the system BP covers tools via
@@ -201,7 +194,8 @@ export class AnthropicProvider {
201
194
  logTag: this.name,
202
195
  });
203
196
  // Fast mode → speed: "fast" on models Anthropic marks as speed-capable.
204
- if (opts.fast === true && supportsAnthropicFastMode(useModel)) {
197
+ // Suppressed while the fast capacity pool is cooling down.
198
+ if (opts.fast === true && supportsAnthropicFastMode(useModel) && fastModeAvailable()) {
205
199
  params.speed = 'fast';
206
200
  this.fastModeBetaHeaderLatched = true;
207
201
  }
@@ -246,15 +240,28 @@ export class AnthropicProvider {
246
240
  fastMode: this.fastModeBetaHeaderLatched,
247
241
  toolSearch: hasDeferredTools,
248
242
  effort: shouldIncludeEffortBeta(useModel, opts),
243
+ serverFallback: params.fallbacks === 'default',
249
244
  }),
250
245
  };
251
246
 
252
247
  const MAX_MIDSTREAM_RETRIES = ANTHROPIC_MAX_MIDSTREAM_RETRIES;
253
248
  let firstAttemptError = null;
254
249
  let firstAttemptClassifier = null;
255
- // Send-scoped stall window: in-place stall retries share one wall
256
- // clock starting at the first stall (see createStallRetryBudget).
257
- const stallRetryBudget = createStallRetryBudget();
250
+ // Shared logical-send window: provider fallback and loop replay consume
251
+ // the same recovery budget.
252
+ const stallRetryBudget = resolveStallRetryBudget(opts);
253
+ const requireTransportRecoveryBudget = (error, controller) => {
254
+ if (stallRetryBudget.allowStallRetry()) return;
255
+ try {
256
+ process.stderr.write(
257
+ `[${this.name}] transport recovery budget exhausted (${STREAM_STALL_RETRY_BUDGET_MS}ms since first failure)\n`,
258
+ );
259
+ } catch {}
260
+ try { controller?.abort?.(error); } catch {}
261
+ throw markProviderRecoveryExhausted(error, {
262
+ owner: `${this.name}-transport-budget`,
263
+ });
264
+ };
258
265
 
259
266
  const buildReturnFromParse = (parseResult) => {
260
267
  const usageRaw = parseResult.usage?.raw || null;
@@ -311,6 +318,9 @@ export class AnthropicProvider {
311
318
  assistantBlocks: Array.isArray(parseResult.assistantBlocks) && parseResult.assistantBlocks.length
312
319
  ? parseResult.assistantBlocks
313
320
  : undefined,
321
+ ...(parseResult.providerMetadata && typeof parseResult.providerMetadata === 'object'
322
+ ? { providerMetadata: parseResult.providerMetadata }
323
+ : {}),
314
324
  usage: {
315
325
  inputTokens: input,
316
326
  outputTokens: output,
@@ -356,6 +366,11 @@ export class AnthropicProvider {
356
366
  },
357
367
  );
358
368
  return buildReturnFromParse(normalizeAnthropicNonStreamingResponse(message, useModel));
369
+ } catch (error) {
370
+ if (error instanceof AnthropicFallbackTriggeredError || totalSignal?.aborted) throw error;
371
+ throw markProviderRecoveryExhausted(error, {
372
+ owner: `${this.name}-nonstreaming-fallback`,
373
+ });
359
374
  } finally {
360
375
  lifetime.cleanup();
361
376
  }
@@ -379,6 +394,7 @@ export class AnthropicProvider {
379
394
  try { streamingError.liveTextEmitted = true; streamingError.unsafeToRetry = true; } catch {}
380
395
  throw streamingError;
381
396
  }
397
+ requireTransportRecoveryBudget(streamingError, streamController);
382
398
  return issueNonStreamingFallback(streamController, streamingError);
383
399
  };
384
400
 
@@ -469,6 +485,12 @@ export class AnthropicProvider {
469
485
  model: useModel,
470
486
  fallbackModel: opts._fallbackTriggered ? undefined : opts.fallbackModel,
471
487
  onRetry: ({ attempt, lastErr, delayMs, delayReason }) => {
488
+ // Long/unknown fast-pool window: replay at
489
+ // standard speed rather than waiting it out.
490
+ if (params?.speed === 'fast'
491
+ && noteFastModeCapacityError(lastErr, { fast: true }) !== 'retry-fast') {
492
+ delete params.speed;
493
+ }
472
494
  const status = Number(lastErr?.httpStatus || lastErr?.status || lastErr?.response?.status || 0);
473
495
  if (status === 429) notifyCurrentAnthropicRateLimit(lastErr);
474
496
  const delayLabel = Number.isFinite(Number(delayMs))
@@ -628,17 +650,12 @@ export class AnthropicProvider {
628
650
  && !midState.emittedToolCall
629
651
  && !midState.partialToolCall
630
652
  && !midState.emittedThinking) {
653
+ requireTransportRecoveryBudget(err, streamController);
631
654
  try { process.stderr.write(`[${this.name}] stream stalled with no exposure — retrying non-streaming\n`); } catch {}
632
655
  return await issueNonStreamingFallback(streamController, err);
633
656
  }
634
- if (classifier === 'stream_stalled' && !stallRetryBudget.allowStallRetry()) {
635
- try {
636
- process.stderr.write(
637
- `[${this.name}] stall retry budget exhausted (${STREAM_STALL_RETRY_BUDGET_MS}ms since first stall) — surfacing for fresh-request retry\n`,
638
- );
639
- } catch {}
640
- try { streamController.abort?.(err); } catch {}
641
- throw err;
657
+ if (classifier === 'stream_stalled') {
658
+ requireTransportRecoveryBudget(err, streamController);
642
659
  }
643
660
  if (classifier && attemptIndex < MAX_MIDSTREAM_RETRIES) {
644
661
  firstAttemptError = err;
@@ -664,6 +681,12 @@ export class AnthropicProvider {
664
681
  );
665
682
  continue;
666
683
  }
684
+ if (classifier && attemptIndex >= MAX_MIDSTREAM_RETRIES) {
685
+ markProviderRecoveryExhausted(err, {
686
+ owner: `${this.name}-midstream`,
687
+ attempts: attemptIndex + 1,
688
+ });
689
+ }
667
690
  if (attemptIndex > 0 && firstAttemptError) {
668
691
  try { err.midstreamRetries = attemptIndex; } catch {}
669
692
  try { err.midstreamClassifier = firstAttemptClassifier; } catch {}
@@ -86,6 +86,27 @@ function antigravityError(res, text, endpoint) {
86
86
  return err;
87
87
  }
88
88
 
89
+ // Endpoint failover only makes sense when the HOST is the suspect. A
90
+ // deterministic 4xx — malformed request, revoked/expired auth, permission
91
+ // denial, unknown route — and a quota decision (429) are answers from the
92
+ // account or the request itself: replaying them across every alternate host
93
+ // repeats the identical rejection, hides the real error behind the last host's
94
+ // message, and can duplicate an accepted-but-throttled generation. Only 5xx and
95
+ // transport-level failures (status 0: DNS/connect/TLS/timeout) fail over.
96
+ function antigravityFailoverEligible(err) {
97
+ if (err?.unsafeToRetry === true || err?.liveTextEmitted === true || err?.emittedToolCall === true) {
98
+ return false;
99
+ }
100
+ const status = Number(err?.status || err?.httpStatus || 0);
101
+ // Strictly 5xx or transport. status 0 means no HTTP response reached us at
102
+ // all (DNS/connect/TLS/first-byte timeout) — the only genuine "this host is
103
+ // the problem" evidence besides a server-side 5xx. ANY other answer,
104
+ // including a 3xx redirect the host chose to return, is that host's real
105
+ // reply and would be reproduced identically everywhere else.
106
+ if (status >= 500) return true;
107
+ return status === 0;
108
+ }
109
+
89
110
  // A signed decimal derived from the first user turn, mirroring the real client.
90
111
  function deriveSessionId(contents) {
91
112
  const first = contents.find((c) => c?.role === 'user');
@@ -211,6 +232,9 @@ export class AntigravityOAuthProvider {
211
232
  const endpoints = this._endpointOrder();
212
233
  let lastErr = null;
213
234
  let response = null;
235
+ // One forced token refresh per send: a second 401 after a fresh token is
236
+ // a real authorization failure, not a stale bearer.
237
+ let refreshedAuth = false;
214
238
  try {
215
239
  for (let index = 0; index < endpoints.length; index += 1) {
216
240
  const endpoint = endpoints[index];
@@ -275,10 +299,40 @@ export class AntigravityOAuthProvider {
275
299
  break;
276
300
  } catch (err) {
277
301
  lastErr = err;
302
+ const status = Number(err?.status || err?.httpStatus || 0);
303
+ const emitted = err?.unsafeToRetry === true
304
+ || err?.liveTextEmitted === true
305
+ || err?.emittedToolCall === true;
306
+ // A typed 401 says THIS access token is no longer accepted —
307
+ // every host would reject it identically. Force one
308
+ // credential refresh and replay the same endpoint instead of
309
+ // failing over with the same dead token (and instead of
310
+ // surfacing a re-login prompt for a token that only needed a
311
+ // refresh). shouldRefresh() alone never covers this: a
312
+ // server-side revocation/rotation happens while the local
313
+ // expiry still looks valid.
314
+ if (status === 401 && !emitted && !refreshedAuth) {
315
+ refreshedAuth = true;
316
+ let refreshed = null;
317
+ try {
318
+ refreshed = await this._ensureAuth({ fetchFn: this._fetch, force: true });
319
+ } catch {
320
+ // Refresh itself failed (revoked / no refresh token):
321
+ // the original 401 is the actionable error.
322
+ throw err;
323
+ }
324
+ // refreshTokens() preserves project_id/email, so only the
325
+ // bearer changes; the already-serialized body stays valid.
326
+ this._projectId = refreshed.projectId;
327
+ headers.Authorization = `Bearer ${refreshed.accessToken}`;
328
+ textLeakGuard = null;
329
+ process.stderr.write('[antigravity] 401 — refreshed credentials and retrying once\n');
330
+ index -= 1;
331
+ continue;
332
+ }
278
333
  // Anything already streamed to the user must not be replayed on
279
334
  // another endpoint, and a terminal decision is not transport luck.
280
- if (isLast || err?.unsafeToRetry === true || err?.liveTextEmitted === true
281
- || err?.emittedToolCall === true) {
335
+ if (isLast || !antigravityFailoverEligible(err)) {
282
336
  throw err;
283
337
  }
284
338
  process.stderr.write(`[antigravity] ${endpoint} failed (${err?.message || err}); trying next endpoint\n`);
@@ -10,8 +10,30 @@ const LOGIN_URL = 'https://cursor.com/loginDeepControl';
10
10
  const POLL_URL = 'https://api2.cursor.sh/auth/poll';
11
11
  const REFRESH_URL = 'https://api2.cursor.sh/auth/exchange_user_api_key';
12
12
  const REFRESH_SKEW_MS = 5 * 60_000;
13
+ // Upper bound for the SHARED token exchange. It replaces the first caller's
14
+ // abort signal, which must never govern work every other waiter depends on.
15
+ const REFRESH_TIMEOUT_MS = 30_000;
13
16
  let refreshInFlight = null;
14
17
 
18
+ // Wait on the shared refresh while still honouring THIS caller's abort signal.
19
+ // Aborting only ends this caller's wait; the exchange keeps running for the
20
+ // remaining waiters and still publishes its result.
21
+ function awaitSharedRefresh(promise, signal) {
22
+ if (!(signal instanceof AbortSignal)) return promise;
23
+ const abortError = () => (signal.reason instanceof Error
24
+ ? signal.reason
25
+ : new Error('Cursor token refresh wait aborted'));
26
+ if (signal.aborted) return Promise.reject(abortError());
27
+ return new Promise((resolve, reject) => {
28
+ const onAbort = () => reject(abortError());
29
+ signal.addEventListener('abort', onAbort, { once: true });
30
+ promise.then(
31
+ (value) => { signal.removeEventListener('abort', onAbort); resolve(value); },
32
+ (err) => { signal.removeEventListener('abort', onAbort); reject(err); },
33
+ );
34
+ });
35
+ }
36
+
15
37
  function credentialsPath() {
16
38
  const bound = boundProviderAuthPath('cursor-oauth');
17
39
  if (bound) return resolve(bound);
@@ -231,7 +253,15 @@ export async function resolveCursorOAuthAccessToken({ forceRefresh = false, fetc
231
253
  if (!latest.refresh_token) {
232
254
  throw new Error('Cursor OAuth refresh token is unavailable. Open /providers in Mixdog to sign in again.');
233
255
  }
234
- const next = await exchangeCursorToken(latest.refresh_token, { fetchFn, signal });
256
+ // Deliberately NOT the caller's signal: this exchange is shared by
257
+ // every concurrent waiter, so letting the first caller's abort
258
+ // (turn cancel / session close) kill it would fail the refresh for
259
+ // all of them and leave the process without a usable token. Its own
260
+ // timeout bounds the request instead.
261
+ const next = await exchangeCursorToken(latest.refresh_token, {
262
+ fetchFn,
263
+ signal: AbortSignal.timeout(REFRESH_TIMEOUT_MS),
264
+ });
235
265
  if (!process.env.CURSOR_ACCESS_TOKEN) {
236
266
  saveCredentials(next);
237
267
  }
@@ -242,7 +272,9 @@ export async function resolveCursorOAuthAccessToken({ forceRefresh = false, fetc
242
272
  secret: true,
243
273
  })
244
274
  .finally(() => { refreshInFlight = null; });
275
+ // Every waiter may abandon its wait; keep the shared rejection observed.
276
+ refreshInFlight.catch(() => {});
245
277
  }
246
- tokens = await refreshInFlight;
278
+ tokens = await awaitSharedRefresh(refreshInFlight, signal);
247
279
  return tokens.access_token;
248
280
  }
@@ -117,7 +117,12 @@ function openCursorStream({ accessToken, path = RUN_PATH, url = API_URL }) {
117
117
  get alive() { return !closed; },
118
118
  write(bytes) {
119
119
  if (closed) return;
120
- resetTimeout();
120
+ // Deliberately NOT resetTimeout(): frames written here (client
121
+ // heartbeat every 5s, tool results) are OUR traffic and say nothing
122
+ // about the server still being alive. Refreshing the deadline on
123
+ // every write let a silent server hold the connection open
124
+ // indefinitely, defeating the 120s silence bound this transport
125
+ // owns. Only 'response'/'data' from the server re-arm it.
121
126
  request.write(bytes);
122
127
  },
123
128
  close,
@@ -383,6 +388,56 @@ function storeActiveRun(key, active) {
383
388
  activeRuns.set(key, active);
384
389
  }
385
390
 
391
+ // Tear down one stored run: drop it from the registry, stop its 5s heartbeat
392
+ // interval, and close the Cursor bridge it was holding open.
393
+ function closeActiveRun(key, active, error) {
394
+ if (!forgetActiveRun(key, active)) return false;
395
+ clearInterval(active.heartbeat);
396
+ try { active.bridge.close(error); } catch { /* already closing */ }
397
+ return true;
398
+ }
399
+
400
+ // A pending tool batch deliberately outlives its HTTP response: the bridge and
401
+ // heartbeat stay up so the next request can resume the same Cursor run. That
402
+ // connection belongs to the session that opened it, so a session close / turn
403
+ // abort must reclaim it instead of leaving it connected until MEMORY_TTL_MS
404
+ // (30 minutes) expires.
405
+ export function closeCursorRunsForSession(sessionId, reason = 'session_closed') {
406
+ const scope = String(sessionId || '').trim();
407
+ if (!scope) return 0;
408
+ let closed = 0;
409
+ for (const [key, active] of [...activeRuns]) {
410
+ if (String(active?.sessionId || '') !== scope) continue;
411
+ if (closeActiveRun(key, active, new Error(`Cursor run closed (${reason})`))) closed += 1;
412
+ }
413
+ return closed;
414
+ }
415
+
416
+ // Process-wide drain (shutdown / exit): no run may keep the daemon's Cursor
417
+ // sockets and heartbeat intervals alive past teardown.
418
+ export function drainCursorRuns(reason = 'shutdown') {
419
+ let closed = 0;
420
+ for (const [key, active] of [...activeRuns]) {
421
+ if (closeActiveRun(key, active, new Error(`Cursor run drained (${reason})`))) closed += 1;
422
+ }
423
+ return closed;
424
+ }
425
+
426
+ // Session-close / drain hooks. Both globals are shared with the openai WS pool,
427
+ // so chain the previously registered handler instead of overwriting it: whoever
428
+ // loads first stays reachable, in either import order.
429
+ const _priorCursorSessionCloseHook = globalThis.__mixdogCloseProviderConnectionsForSession;
430
+ globalThis.__mixdogCloseProviderConnectionsForSession = (sessionId, reason) => {
431
+ try { _priorCursorSessionCloseHook?.(sessionId, reason); } finally {
432
+ closeCursorRunsForSession(sessionId, reason);
433
+ }
434
+ };
435
+ const _priorCursorDrainHook = globalThis.__mixdogDrainProviderConnections;
436
+ globalThis.__mixdogDrainProviderConnections = (reason) => {
437
+ try { _priorCursorDrainHook?.(reason); } finally { drainCursorRuns(reason); }
438
+ };
439
+ process.on('exit', () => { drainCursorRuns('process-exit'); });
440
+
386
441
  function conversationKey(messages, sessionId = '') {
387
442
  const scope = String(sessionId || '').trim();
388
443
  if (scope) {
@@ -889,6 +944,9 @@ function createStreamResponse({
889
944
  cloudRule,
890
945
  model,
891
946
  key,
947
+ // Owning session, carried into the stored run so a session close can find
948
+ // and tear down the pending batch's bridge/heartbeat.
949
+ sessionId = '',
892
950
  sawTurnEnded = false,
893
951
  }) {
894
952
  const id = `chatcmpl-${crypto.randomUUID().replaceAll('-', '').slice(0, 28)}`;
@@ -944,6 +1002,7 @@ function createStreamResponse({
944
1002
  conversation,
945
1003
  tools,
946
1004
  cloudRule,
1005
+ sessionId,
947
1006
  pending: state.pending,
948
1007
  sawTurnEnded: state.sawTurnEnded,
949
1008
  });
@@ -1183,6 +1242,7 @@ export async function handleChatCompletion(body, accessToken) {
1183
1242
  cloudRule: parsed.systems.join('\n\n') || undefined,
1184
1243
  model,
1185
1244
  key,
1245
+ sessionId,
1186
1246
  });
1187
1247
  }
1188
1248
 
@@ -447,7 +447,6 @@ export async function consumeGeminiRestStreamResponse(response, { signal, onStre
447
447
  }
448
448
  const { done, value } = chunk;
449
449
  if (done) break;
450
- resetIdleTimer();
451
450
  buffer += decoder.decode(value, { stream: true });
452
451
  const payloads = [];
453
452
  let lineEnd;
@@ -483,6 +482,12 @@ export async function consumeGeminiRestStreamResponse(response, { signal, onStre
483
482
  relayedText += relayGeminiStreamText(t, { onTextDelta, textLeakGuard });
484
483
  }
485
484
  }
485
+ // Re-arm on SEMANTIC progress only. A decoded SSE payload is real
486
+ // generation output; raw byte chunks (a partial data: line, blank
487
+ // keepalive lines, proxy padding) are not. Resetting on every read
488
+ // let a stalled generation that still dribbles bytes hold the idle
489
+ // watchdog off indefinitely.
490
+ if (parsedChunks.length) resetIdleTimer();
486
491
  }
487
492
  if (buffer.trim()) {
488
493
  const line = buffer.trim().replace(/\r$/, '');