mixdog 0.9.147 → 0.9.149

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 (265) 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 +5 -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-codex-identity.test.mjs +32 -9
  48. package/src/runtime/agent/orchestrator/providers/openai-codex-metadata.mjs +78 -33
  49. package/src/runtime/agent/orchestrator/providers/openai-codex-model.mjs +21 -0
  50. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +36 -36
  51. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +16 -6
  52. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +50 -213
  53. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +135 -126
  54. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +20 -11
  55. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +112 -36
  56. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +220 -50
  57. package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +112 -9
  58. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +11 -0
  59. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +130 -60
  60. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +2 -2
  61. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +28 -10
  62. package/src/runtime/agent/orchestrator/providers/registry.mjs +122 -95
  63. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +69 -3
  64. package/src/runtime/agent/orchestrator/providers/tool-stream-state.mjs +4 -22
  65. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +7 -14
  66. package/src/runtime/agent/orchestrator/session/compact-policy.test.mjs +128 -0
  67. package/src/runtime/agent/orchestrator/session/context-compaction-policy.mjs +5 -0
  68. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +27 -48
  69. package/src/runtime/agent/orchestrator/session/loop/steering.mjs +19 -14
  70. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +9 -43
  71. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +5 -1
  72. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +43 -5
  73. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +15 -2
  74. package/src/runtime/agent/orchestrator/session/manager/pending-messages-persistence.test.mjs +325 -0
  75. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +599 -195
  76. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +22 -0
  77. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +9 -2
  78. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +2 -0
  79. package/src/runtime/agent/orchestrator/session/manager.mjs +3 -0
  80. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +26 -16
  81. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +23 -11
  82. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +118 -3
  83. package/src/runtime/agent/orchestrator/session/store-summary-reader.test.mjs +332 -0
  84. package/src/runtime/agent/orchestrator/session/store.mjs +38 -1
  85. package/src/runtime/agent/orchestrator/tools/builtin/advisory-lock.mjs +27 -6
  86. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +12 -20
  87. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +291 -128
  88. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +46 -9
  89. package/src/runtime/agent/orchestrator/tools/builtin/edit-encoding-eol.test.mjs +224 -0
  90. package/src/runtime/agent/orchestrator/tools/builtin/edit-sequential-occupation.test.mjs +53 -3
  91. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +255 -25
  92. package/src/runtime/agent/orchestrator/tools/builtin/find-search-budget.test.mjs +87 -0
  93. package/src/runtime/agent/orchestrator/tools/builtin/git-command-policy.mjs +1 -1
  94. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +132 -9
  95. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.test.mjs +10 -0
  96. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-pattern-fanout.mjs +29 -3
  97. package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-job-records.mjs +1 -1
  98. package/src/runtime/agent/orchestrator/tools/builtin/list-tool-integrity.test.mjs +14 -0
  99. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +180 -31
  100. package/src/runtime/agent/orchestrator/tools/builtin/local-search-telemetry.mjs +4 -1
  101. package/src/runtime/agent/orchestrator/tools/builtin/native-search-client.mjs +32 -5
  102. package/src/runtime/agent/orchestrator/tools/builtin/native-search-health.test.mjs +5 -2
  103. package/src/runtime/agent/orchestrator/tools/builtin/native-search-runner.mjs +10 -1
  104. package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +38 -10
  105. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +12 -0
  106. package/src/runtime/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +31 -1
  107. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +11 -1
  108. package/src/runtime/agent/orchestrator/tools/builtin/read-windows.mjs +22 -4
  109. package/src/runtime/agent/orchestrator/tools/builtin/runtime-capabilities.mjs +6 -8
  110. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +25 -24
  111. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +152 -133
  112. package/src/runtime/agent/orchestrator/tools/builtin/task-tool.mjs +114 -42
  113. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +129 -15
  114. package/src/runtime/agent/orchestrator/tools/code-graph/loose-file-target.test.mjs +72 -0
  115. package/src/runtime/agent/orchestrator/tools/code-graph/project-root.mjs +7 -8
  116. package/src/runtime/agent/orchestrator/tools/code-graph/search-references.mjs +4 -4
  117. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +201 -19
  118. package/src/runtime/agent/orchestrator/tools/code-graph/span.mjs +26 -3
  119. package/src/runtime/agent/orchestrator/tools/code-graph/symbol-index.mjs +10 -1
  120. package/src/runtime/agent/orchestrator/tools/code-graph/symbol-search-scope.test.mjs +55 -0
  121. package/src/runtime/agent/orchestrator/tools/code-graph/text-mask.mjs +115 -25
  122. package/src/runtime/agent/orchestrator/tools/code-graph/text-span-mask.test.mjs +268 -0
  123. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +3 -3
  124. package/src/runtime/agent/orchestrator/tools/lib/native-spawn-client.mjs +296 -46
  125. package/src/runtime/agent/orchestrator/tools/lib/native-spawn-client.test.mjs +104 -0
  126. package/src/runtime/agent/orchestrator/tools/lib/shell-descendants.mjs +136 -0
  127. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +127 -38
  128. package/src/runtime/agent/orchestrator/tools/patch/engine-contract-gate.test.mjs +661 -0
  129. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +350 -9
  130. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +418 -44
  131. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +27 -3
  132. package/src/runtime/agent/orchestrator/tools/patch/patch-codec-eol.test.mjs +552 -0
  133. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +65 -15
  134. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
  135. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +284 -109
  136. package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +51 -21
  137. package/src/runtime/agent/orchestrator/tools/shell-exec-policy.mjs +44 -27
  138. package/src/runtime/agent/orchestrator/tools/shell-policy.mjs +6 -1
  139. package/src/runtime/agent/orchestrator/tools/spawn-manifest.json +11 -11
  140. package/src/runtime/attachments/store.mjs +11 -3
  141. package/src/runtime/channels/data/voice-runtime-manifest.json +46 -70
  142. package/src/runtime/channels/lib/event-queue.mjs +51 -41
  143. package/src/runtime/channels/lib/scheduler.mjs +23 -28
  144. package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +6 -17
  145. package/src/runtime/channels/lib/voice-runtime-fetcher.test.mjs +17 -0
  146. package/src/runtime/channels/lib/webhook.mjs +39 -20
  147. package/src/runtime/channels/lib/whisper-server.mjs +46 -1
  148. package/src/runtime/channels/lib/worker-main.mjs +3 -1
  149. package/src/runtime/media/adapters/gemini-video.mjs +20 -4
  150. package/src/runtime/media/jobs.mjs +13 -0
  151. package/src/runtime/media/renditions.mjs +7 -1
  152. package/src/runtime/media/store.mjs +42 -22
  153. package/src/runtime/memory/index.mjs +4 -9
  154. package/src/runtime/memory/lib/agent-ipc.mjs +1 -6
  155. package/src/runtime/memory/lib/archive-security.test.mjs +21 -1
  156. package/src/runtime/memory/lib/core-memory-store.mjs +28 -50
  157. package/src/runtime/memory/lib/embedding-model-config.mjs +37 -1
  158. package/src/runtime/memory/lib/embedding-model-config.test.mjs +33 -0
  159. package/src/runtime/memory/lib/embedding-provider.mjs +34 -12
  160. package/src/runtime/memory/lib/embedding-worker.mjs +37 -5
  161. package/src/runtime/memory/lib/ko-morph.mjs +46 -15
  162. package/src/runtime/memory/lib/memory-action-handlers.mjs +18 -1
  163. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -18
  164. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +1 -18
  165. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +98 -45
  166. package/src/runtime/memory/lib/memory-cycle2-shared.mjs +71 -0
  167. package/src/runtime/memory/lib/memory-cycle2-shared.test.mjs +78 -0
  168. package/src/runtime/memory/lib/memory-cycle2.mjs +37 -3
  169. package/src/runtime/memory/lib/memory-cycle3.mjs +1 -1
  170. package/src/runtime/memory/lib/memory-embedding-migration.test.mjs +50 -0
  171. package/src/runtime/memory/lib/memory-recall-store.mjs +9 -24
  172. package/src/runtime/memory/lib/memory.mjs +50 -12
  173. package/src/runtime/memory/lib/pg/process.mjs +1 -10
  174. package/src/runtime/memory/lib/pg/supervisor.mjs +1 -9
  175. package/src/runtime/memory/lib/query-handlers.mjs +2 -4
  176. package/src/runtime/memory/lib/session-ingest-runtime.mjs +2 -2
  177. package/src/runtime/shared/background-tasks.mjs +15 -0
  178. package/src/runtime/shared/background-tasks.test.mjs +78 -0
  179. package/src/runtime/shared/child-guardian.mjs +2 -10
  180. package/src/runtime/shared/pid-liveness.mjs +27 -0
  181. package/src/runtime/shared/pristine-execution-contract.json +0 -1
  182. package/src/runtime/shared/runtime-root.mjs +1 -11
  183. package/src/runtime/shared/service-discovery.mjs +1 -17
  184. package/src/runtime/shared/singleton-owner.mjs +1 -11
  185. package/src/runtime/shared/staged-update.mjs +2 -12
  186. package/src/runtime/shared/tool-status.mjs +5 -1
  187. package/src/runtime/shared/tool-surface.mjs +15 -7
  188. package/src/runtime/shared/turn-snapshot-store.mjs +168 -0
  189. package/src/runtime/shared/turn-snapshot.mjs +137 -13
  190. package/src/runtime/shared/turn-worktree-snapshot.mjs +110 -18
  191. package/src/runtime/shared/webhook-session-run.mjs +16 -2
  192. package/src/runtime/web-search/lib/http-fetch.mjs +34 -4
  193. package/src/runtime/web-search/lib/state.mjs +15 -15
  194. package/src/runtime/web-search/lib/web-tools.mjs +46 -5
  195. package/src/session-runtime/context-status.mjs +4 -12
  196. package/src/session-runtime/runtime-core.mjs +20 -1
  197. package/src/session-runtime/session-lifecycle.mjs +9 -0
  198. package/src/standalone/agent-dispatch-broker.mjs +33 -2
  199. package/src/standalone/agent-tool/job-task-reconcile.mjs +78 -0
  200. package/src/standalone/agent-tool/notify.mjs +3 -1
  201. package/src/standalone/agent-tool/spawn-flow.mjs +52 -70
  202. package/src/standalone/agent-tool/tag-registry.mjs +116 -33
  203. package/src/standalone/agent-tool/tool-def.mjs +2 -2
  204. package/src/standalone/agent-tool/worker-index.mjs +8 -11
  205. package/src/standalone/agent-tool/worker-rows.mjs +4 -1
  206. package/src/standalone/agent-tool.mjs +20 -55
  207. package/src/standalone/agent-watchdog-registry.mjs +28 -4
  208. package/src/standalone/channel-client.mjs +47 -15
  209. package/src/standalone/channel-transport.mjs +44 -25
  210. package/src/standalone/daemon.mjs +31 -2
  211. package/src/standalone/hook-bus/config.mjs +25 -9
  212. package/src/standalone/hook-bus/handlers.mjs +16 -52
  213. package/src/standalone/hook-bus.mjs +29 -23
  214. package/src/standalone/memory-runtime-proxy.mjs +1 -16
  215. package/src/standalone/session-client.mjs +86 -23
  216. package/src/standalone/session-protocol.mjs +0 -2
  217. package/src/standalone/session-runtime-host-health.test.mjs +5 -4
  218. package/src/standalone/session-runtime-host.mjs +101 -6
  219. package/src/standalone/session-runtime-record.mjs +1 -5
  220. package/src/standalone/session-runtime-worker.mjs +52 -57
  221. package/src/standalone/session-service.mjs +4 -1
  222. package/src/standalone/session-transport.mjs +56 -25
  223. package/src/tui/App.jsx +40 -49
  224. package/src/tui/app/app-view.jsx +14 -21
  225. package/src/tui/app/core-memory-picker.mjs +29 -16
  226. package/src/tui/app/create-app-pickers.mjs +15 -36
  227. package/src/tui/app/extension-pickers.mjs +60 -49
  228. package/src/tui/app/maintenance-pickers.mjs +100 -57
  229. package/src/tui/app/message-selector.mjs +6 -5
  230. package/src/tui/app/model-picker.mjs +30 -13
  231. package/src/tui/app/onboarding-steps.mjs +21 -12
  232. package/src/tui/app/panel-epoch.mjs +56 -0
  233. package/src/tui/app/panel-epoch.test.mjs +1009 -0
  234. package/src/tui/app/panel-surface.mjs +98 -0
  235. package/src/tui/app/project-picker.mjs +30 -29
  236. package/src/tui/app/prompt-submit.mjs +192 -97
  237. package/src/tui/app/prompt-submit.test.mjs +206 -0
  238. package/src/tui/app/provider-setup-picker.mjs +74 -43
  239. package/src/tui/app/resume-picker.mjs +11 -8
  240. package/src/tui/app/route-pickers.mjs +45 -28
  241. package/src/tui/app/settings-picker.mjs +69 -18
  242. package/src/tui/app/shell-layout.mjs +1 -3
  243. package/src/tui/app/slash-commands.mjs +0 -2
  244. package/src/tui/app/slash-dispatch.mjs +4 -2
  245. package/src/tui/app/text-entry-policy.mjs +25 -0
  246. package/src/tui/app/theme-effort-pickers.mjs +11 -16
  247. package/src/tui/app/usage-context-panels.mjs +21 -25
  248. package/src/tui/app/use-global-key-input.mjs +3 -2
  249. package/src/tui/app/use-prompt-draft-flow.mjs +6 -18
  250. package/src/tui/app/use-prompt-handlers.mjs +3 -2
  251. package/src/tui/app/use-welcome-prompt-hint.mjs +17 -3
  252. package/src/tui/components/Picker.jsx +1 -1
  253. package/src/tui/components/TextEntryPanel.jsx +7 -2
  254. package/src/tui/dist/index.mjs +652 -561
  255. package/src/tui/lib/voice-setup.mjs +1 -2
  256. package/src/tui/session/agent-job-feed.mjs +73 -11
  257. package/src/tui/session/session-api-ext.mjs +5 -6
  258. package/src/tui/session/session-api.mjs +1 -0
  259. package/src/tui/session/tool-approval.mjs +15 -5
  260. package/src/tui/session/tui-steering-persist.mjs +33 -19
  261. package/src/tui/session/tui-steering-persist.test.mjs +104 -0
  262. package/src/tui/session-local.mjs +0 -22
  263. package/scripts/.tmp-coverage-report.mjs +0 -96
  264. package/src/runtime/agent/orchestrator/tools/builtin/shell-monitor.mjs +0 -57
  265. package/src/runtime/agent/orchestrator/tools/builtin/shell-monitor.test.mjs +0 -57
@@ -354,9 +354,40 @@ export class GeminiProvider {
354
354
  _attachGeminiCacheState(opts, globalCache, currentIter);
355
355
  return globalCache.cacheName;
356
356
  }
357
+ // Wait on a shared create while still honouring THIS caller's abort:
358
+ // the create itself is process-global and must outlive any single
359
+ // session, so the caller only stops waiting (resolving null) instead of
360
+ // cancelling the work for everyone.
361
+ const awaitSharedCreate = (task) => {
362
+ const signal = opts.signal;
363
+ if (!(signal instanceof AbortSignal)) return task.then((v) => v, () => null);
364
+ if (signal.aborted) return Promise.resolve(null);
365
+ return new Promise((resolve) => {
366
+ const onAbort = () => resolve(null);
367
+ signal.addEventListener('abort', onAbort, { once: true });
368
+ task.then(
369
+ (value) => { signal.removeEventListener('abort', onAbort); resolve(value); },
370
+ () => { signal.removeEventListener('abort', onAbort); resolve(null); },
371
+ );
372
+ });
373
+ };
374
+ // Strict singleflight: a WAITER never inherits the creation duty.
375
+ //
376
+ // The earlier bounded wait loop still herded. When the shared create
377
+ // settles, its own cleanup removes the slot BEFORE the waiters resume,
378
+ // so every waiter saw an empty slot; and a waiter that exhausted its
379
+ // rounds fell through and created even while another create was in
380
+ // flight — 8 waiters over repeated failures produced ~4 concurrent
381
+ // cachedContents POSTs for one prefix.
382
+ //
383
+ // The rule is now unconditional: if a create is in flight we wait for
384
+ // exactly that one, and if it does not yield a cache we proceed
385
+ // UNCACHED (the cache is an optimization, and the next turn re-creates).
386
+ // Only the caller that finds an EMPTY slot creates, and it publishes its
387
+ // task synchronously below, so at most one create per key can exist.
357
388
  const inFlightCreate = geminiGlobalCacheCreates.get(globalCacheKey);
358
389
  if (inFlightCreate) {
359
- const created = await inFlightCreate.catch(() => null);
390
+ const created = await awaitSharedCreate(inFlightCreate);
360
391
  if (created?.cacheName) {
361
392
  try {
362
393
  appendAgentTrace({
@@ -374,6 +405,14 @@ export class GeminiProvider {
374
405
  _attachGeminiCacheState(opts, created, currentIter);
375
406
  return created.cacheName;
376
407
  }
408
+ // Failed or abandoned wait: another caller may still have published
409
+ // a usable cache for this exact prefix meanwhile.
410
+ const raced = _getGeminiGlobalCache(globalCacheKey, Date.now());
411
+ if (raced) {
412
+ _attachGeminiCacheState(opts, raced, currentIter);
413
+ return raced.cacheName;
414
+ }
415
+ return canAttachState ? state.cacheName : null;
377
416
  }
378
417
  const createTask = (async () => {
379
418
  try {
@@ -398,12 +437,14 @@ export class GeminiProvider {
398
437
  body.contents = cachePrefixContents;
399
438
  }
400
439
  const url = `https://generativelanguage.googleapis.com/v1beta/cachedContents?key=${encodeURIComponent(apiKey)}`;
401
- // Honor the external session abort signal during cache creation, not
402
- // only the 20s ceiling. Without merging opts.signal a session that is
403
- // aborted (stall-watchdog / closeSession) mid-cache-create leaves this
404
- // preflight request running until its own timeout fires.
440
+ // Deliberately NOT merged with opts.signal. This create is the
441
+ // process-global singleflight every concurrent session waits on, so
442
+ // the FIRST caller's abort (stall-watchdog / closeSession) must not
443
+ // cancel the cache every other session is waiting for — that is the
444
+ // herd trigger. The 20s ceiling still bounds the preflight request,
445
+ // and an aborting caller simply stops waiting (awaitSharedCreate).
405
446
  const createTotal = createTimeoutSignal(
406
- opts.signal,
447
+ null,
407
448
  PROVIDER_CACHE_CREATE_TOTAL_TIMEOUT_MS,
408
449
  'Gemini cachedContents.create total',
409
450
  );
@@ -520,18 +561,23 @@ export class GeminiProvider {
520
561
  }
521
562
  })();
522
563
  geminiGlobalCacheCreates.set(globalCacheKey, createTask);
523
- try {
524
- const created = await createTask;
525
- // A failed refresh must not silently retain a cache that may have
526
- // expired or been evicted server-side. The caller proceeds uncached.
527
- if (!created?.cacheName) return null;
528
- _attachGeminiCacheState(opts, created, currentIter);
529
- return created.cacheName;
530
- } finally {
564
+ // Ownership is released by the TASK, never by the awaiting caller: a
565
+ // caller that walks away (abort) must not retract a still-running create
566
+ // from the map, or the next caller starts a duplicate POST for the same
567
+ // prefix — the herd this singleflight exists to prevent.
568
+ createTask.finally(() => {
531
569
  if (geminiGlobalCacheCreates.get(globalCacheKey) === createTask) {
532
570
  geminiGlobalCacheCreates.delete(globalCacheKey);
533
571
  }
534
- }
572
+ });
573
+ // Owner path: same abort semantics as the waiters — the shared create
574
+ // continues even if this caller stops waiting.
575
+ const created = await awaitSharedCreate(createTask);
576
+ // A failed refresh must not silently retain a cache that may have
577
+ // expired or been evicted server-side. The caller proceeds uncached.
578
+ if (!created?.cacheName) return null;
579
+ _attachGeminiCacheState(opts, created, currentIter);
580
+ return created.cacheName;
535
581
  }
536
582
 
537
583
  async send(messages, model, tools, sendOpts) {
@@ -1,5 +1,11 @@
1
1
  import { providerNativeToolPrefixCount } from '../../../../../session-runtime/provider-request-tools.mjs';
2
2
  import { isNativeServerToolBlockType } from './anthropic-native-blocks.mjs';
3
+ import { sanitizeAnthropicContentPairs, foldUserTextIntoToolResultTail } from '../../session/context-utils.mjs';
4
+ import { normalizeContentForAnthropic } from '../media-normalization.mjs';
5
+ import {
6
+ anthropicFallbackProviderMetadata,
7
+ parseAnthropicFallbackBlock,
8
+ } from '../anthropic-server-fallback.mjs';
3
9
 
4
10
  export const ANTHROPIC_CACHE_TTL_STABLE = { type: 'ephemeral', ttl: '1h' };
5
11
  export const ANTHROPIC_CACHE_TTL_VOLATILE = { type: 'ephemeral' };
@@ -47,6 +53,117 @@ export function resolveAnthropicCacheTtls(opts) {
47
53
  return resolved;
48
54
  }
49
55
 
56
+ // Message-tail cache budget, shared by both Anthropic providers.
57
+ //
58
+ // 4-BP layout: the tools breakpoint is dropped — the system BP covers the tools
59
+ // prefix via Anthropic's prompt-cache prefix semantics (order: tools → system →
60
+ // messages), which frees its slot for the messages tail. The system blocks hold
61
+ // BP1/BP2/BP3 (tier3), so every consumed slot is already counted there.
62
+ //
63
+ // Env override: ANTHROPIC_MSG_SLOTS=0 disables message caching entirely; any
64
+ // value >=1 first marks the previous user text turn so consecutive requests
65
+ // share a breakpoint, and a second free slot marks the tail for the newest
66
+ // delta. messageTtl === null (no slot, or ttls.messages disabled) turns the
67
+ // tail off.
68
+ export function resolveAnthropicMessageCacheSlots(systemBlocks, ttls) {
69
+ const usedSlots = systemBlocks.filter(b => b.cache_control).length;
70
+ const slotsCap = Number.parseInt(process.env.ANTHROPIC_MSG_SLOTS, 10);
71
+ const defaultSlots = Math.max(0, 4 - usedSlots);
72
+ const messageSlots = ttls.messages
73
+ ? (Number.isFinite(slotsCap) && slotsCap >= 0 ? Math.min(slotsCap, defaultSlots) : defaultSlots)
74
+ : 0;
75
+ // Key order matches the object both call sites built inline: messageTtl
76
+ // first, then messageSlots.
77
+ return { messageTtl: messageSlots > 0 ? ttls.messages : null, messageSlots };
78
+ }
79
+
80
+ // Single lowering of orchestrator messages to the Anthropic wire shape. The
81
+ // API-key provider (anthropic.mjs, via anthropic-messages.mjs) and the OAuth
82
+ // provider (anthropic-oauth.mjs) both used to carry byte-identical copies;
83
+ // they now share this one.
84
+ //
85
+ // Marker-free lowering. cache_control is applied AFTER sanitization by
86
+ // applyAnthropicCacheMarkers() so that block drops/inserts/reorders performed
87
+ // by sanitizeAnthropicContentPairs cannot move or delete a marked block (the
88
+ // root cause of the sporadic COLD-turn cache miss: pre-sanitize markers landed
89
+ // on blocks the sanitizer then rewrote, so the provider-visible breakpoint
90
+ // diverged from the cached one).
91
+ export function toAnthropicMessages(messages) {
92
+ const result = [];
93
+ for (let idx = 0; idx < messages.length; idx++) {
94
+ const m = messages[idx];
95
+ if (m.role === 'system') continue;
96
+ if (m.role === 'assistant' && (m.toolCalls?.length || m.assistantBlocks?.length || m.thinkingBlocks?.length)) {
97
+ let content;
98
+ if (m.assistantBlocks?.length) {
99
+ content = m.assistantBlocks.slice();
100
+ } else {
101
+ content = [];
102
+ // Adaptive-thinking round-trip: prior-turn thinking blocks are
103
+ // REQUIRED back, unmodified (signature intact; empty thinking
104
+ // field allowed), and MUST precede tool_use blocks. Emit them
105
+ // first, verbatim as received from the SSE parser.
106
+ if (Array.isArray(m.thinkingBlocks)) {
107
+ for (const tb of m.thinkingBlocks) {
108
+ if (tb && typeof tb === 'object') content.push(tb);
109
+ }
110
+ }
111
+ if (m.content) content.push({ type: 'text', text: m.content });
112
+ for (const tc of m.toolCalls || []) {
113
+ content.push({
114
+ type: 'tool_use',
115
+ id: tc.id,
116
+ name: tc.name,
117
+ input: tc.arguments,
118
+ });
119
+ }
120
+ }
121
+ result.push({ role: 'assistant', content });
122
+ continue;
123
+ }
124
+ if (m.role === 'tool') {
125
+ const last = result[result.length - 1];
126
+ const native = m.nativeToolSearch;
127
+ const nativeProvider = String(native?.provider || '').toLowerCase();
128
+ const anthropicNative = new Set(['anthropic', 'anthropic-oauth']);
129
+ const references = (!nativeProvider || anthropicNative.has(nativeProvider))
130
+ && Array.isArray(native?.toolReferences)
131
+ ? native.toolReferences.map((name) => String(name || '').trim()).filter(Boolean)
132
+ : [];
133
+ const block = {
134
+ type: 'tool_result',
135
+ tool_use_id: m.toolCallId || '',
136
+ content: references.length
137
+ ? references.map((tool_name) => ({ type: 'tool_reference', tool_name }))
138
+ : normalizeContentForAnthropic(m.content),
139
+ ...((m.toolKind === 'error' || m.isError === true) ? { is_error: true } : {}),
140
+ };
141
+ if (last?.role === 'user' && Array.isArray(last.content)) {
142
+ last.content.push(block);
143
+ } else {
144
+ result.push({ role: 'user', content: [block] });
145
+ }
146
+ continue;
147
+ }
148
+ // First-party client parity: fold a user text turn that directly follows a
149
+ // tool_result turn into that tool_result's content. A sibling text turn
150
+ // after tool_result renders as `</function_results>\n\nHuman:` on the wire
151
+ // and trains the model toward 3-token empty end_turn completions (empty
152
+ // end_turn livelock prevention; see foldUserTextIntoToolResultTail).
153
+ // EXCEPTION: steering-origin user messages (human/TUI interjections)
154
+ // keep their own user turn so provenance survives — folding them would
155
+ // disguise user input as tool output. Anthropic accepts a user text
156
+ // message after a tool_result message, so the turn stays request-valid.
157
+ const isSteering = m.role === 'user' && m.meta?.source === 'steering';
158
+ if (m.role === 'user' && !isSteering
159
+ && foldUserTextIntoToolResultTail(result, normalizeContentForAnthropic(m.content))) {
160
+ continue;
161
+ }
162
+ result.push({ role: m.role, content: normalizeContentForAnthropic(m.content) });
163
+ }
164
+ return sanitizeAnthropicContentPairs(result);
165
+ }
166
+
50
167
  export function clampAnthropicThinkingBudget(value, maxTokens) {
51
168
  const desired = Math.floor(Number(value));
52
169
  const max = Math.floor(Number(maxTokens));
@@ -63,6 +180,7 @@ const REPLAYABLE_ASSISTANT_BLOCK_TYPES = new Set(['thinking', 'redacted_thinking
63
180
 
64
181
  export function normalizeAnthropicNonStreamingResponse(message, fallbackModel = '') {
65
182
  const blocks = Array.isArray(message?.content) ? message.content : [];
183
+ const fallbackEvents = blocks.map(parseAnthropicFallbackBlock).filter(Boolean);
66
184
  const text = blocks
67
185
  .filter((block) => block?.type === 'text')
68
186
  .map((block) => String(block.text || ''))
@@ -99,13 +217,14 @@ export function normalizeAnthropicNonStreamingResponse(message, fallbackModel =
99
217
  const cacheWrite = Number(usage.cache_creation_input_tokens) || 0;
100
218
  return {
101
219
  content: text,
102
- model: message?.model || fallbackModel,
220
+ model: fallbackEvents.at(-1)?.fallbackModel || message?.model || fallbackModel,
103
221
  toolCalls: toolCalls.length ? toolCalls : undefined,
104
222
  stopReason: message?.stop_reason || null,
105
223
  hasThinkingContent: thinkingBlocks.length > 0,
106
224
  contentBlockTypes: blocks.map((block) => block?.type).filter(Boolean),
107
225
  thinkingBlocks: thinkingBlocks.length ? thinkingBlocks : undefined,
108
226
  assistantBlocks: nativeAssistantBlocks.length ? nativeAssistantBlocks : undefined,
227
+ providerMetadata: anthropicFallbackProviderMetadata(fallbackEvents),
109
228
  usage: {
110
229
  inputTokens: input,
111
230
  outputTokens: Number(usage.output_tokens) || 0,
@@ -1,3 +1,12 @@
1
+ // Shared env-boolean parser: unset/empty keeps `fallback`, and only the
2
+ // documented off-values flip a default-on flag. Single implementation behind
3
+ // the historical `_envFlag` name used by the OpenAI/xAI transport gates.
4
+ export function envFlag(name, fallback = true) {
5
+ const raw = process.env[name];
6
+ if (raw == null || raw === '') return fallback;
7
+ return !['0', 'false', 'off', 'no'].includes(String(raw).toLowerCase());
8
+ }
9
+
1
10
  export function envPositiveInt(name, fallback) {
2
11
  const raw = process.env[name];
3
12
  if (raw == null || raw === '') return fallback;
@@ -277,9 +277,6 @@ export function readStreamOutcome(...sources) {
277
277
  return _finalize(signals || EMPTY_SIGNALS)
278
278
  }
279
279
 
280
- /** Alias kept for callers that read a provider midState directly. */
281
- export const streamOutcomeFromState = readStreamOutcome
282
-
283
280
  /**
284
281
  * Attach the canonical record to `target` (usually a thrown error) and mirror
285
282
  * it onto the historical alias flags. Aliases are only ever SET, never
@@ -1,13 +1,52 @@
1
- import { existsSync, readFileSync } from 'node:fs';
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, readFileSync, statSync } from 'node:fs';
2
3
  import { join } from 'node:path';
3
4
  import { resolvePluginData } from '../../../shared/plugin-paths.mjs';
5
+ import { cursorTokenExpiry } from './cursor-auth.mjs';
4
6
 
5
7
  const ANTHROPIC_DEFAULT_CREDENTIALS_PATH = join(resolvePluginData(), 'anthropic-oauth-credentials.json');
6
8
 
7
- function readJsonIfExists(path) {
8
- if (!path || !existsSync(path)) return null;
9
- try { return JSON.parse(readFileSync(path, 'utf-8')); }
10
- catch { return null; }
9
+ /**
10
+ * Read one credential file with THREE distinguishable outcomes:
11
+ * { present: false } → the file is not there (logged out)
12
+ * { present: true, unreadable: true } → it exists but cannot be read/parsed
13
+ * right now (EACCES, lock contention,
14
+ * a torn write mid atomic-replace)
15
+ * { present: true, data } → parsed content
16
+ * Collapsing the middle case into "absent" is what let a momentary FS failure
17
+ * be mistaken for a deliberate logout by every caller downstream.
18
+ */
19
+ function readCredentialFile(path) {
20
+ if (!path) return { present: false, data: null, unreadable: false };
21
+ let raw;
22
+ try {
23
+ if (!existsSync(path)) return { present: false, data: null, unreadable: false };
24
+ raw = readFileSync(path, 'utf-8');
25
+ } catch {
26
+ return { present: true, data: null, unreadable: true };
27
+ }
28
+ try {
29
+ return { present: true, data: JSON.parse(raw), unreadable: false };
30
+ } catch {
31
+ return { present: true, data: null, unreadable: true };
32
+ }
33
+ }
34
+
35
+ // Resolve a probe to 'present' | 'absent' | 'unreadable'. `evaluate` sees only
36
+ // the documents that parsed; if none of them yields usable credentials while at
37
+ // least one candidate file was unreadable, the answer is "cannot tell", never
38
+ // "absent".
39
+ function resolveProbeState(paths, evaluate) {
40
+ let sawUnreadable = false;
41
+ const docs = [];
42
+ for (const path of paths) {
43
+ if (!path) continue;
44
+ const file = readCredentialFile(path);
45
+ if (file.unreadable) { sawUnreadable = true; continue; }
46
+ if (file.data) docs.push(file.data);
47
+ }
48
+ if (evaluate(docs) === true) return 'present';
49
+ return sawUnreadable ? 'unreadable' : 'absent';
11
50
  }
12
51
 
13
52
  function pushUnique(list, value) {
@@ -17,113 +56,177 @@ function pushUnique(list, value) {
17
56
 
18
57
  // Short-TTL memo. Under simultaneous multi-agent launch, buildDefaultConfig
19
58
  // and registry lazy-init call these probes in bursts; each probe does
20
- // synchronous existsSync + readFileSync disk work. Cache the boolean per key
59
+ // synchronous existsSync + readFileSync disk work. Cache the STATE per key
21
60
  // for a brief window so one launch burst shares a single read instead of N.
22
61
  // Kept short so the registry's "re-probe on each getProvider miss" self-heal
23
62
  // (a credential file appearing/changing) is delayed by at most PROBE_TTL_MS.
24
63
  const PROBE_TTL_MS = 3000;
25
64
  const _probeCache = new Map();
26
- function memoProbe(key, compute) {
65
+
66
+ // Cheap change-detector for the candidate credential files. statSync is orders
67
+ // of magnitude cheaper than the readFileSync + JSON.parse the memo exists to
68
+ // coalesce, so it runs on every call: the memo may only return a cached answer
69
+ // while the files it was computed from are byte-for-byte the same. Without it a
70
+ // logout stayed invisible for up to PROBE_TTL_MS — long enough for one
71
+ // immediate config reload to re-observe 'present' and keep a revoked provider
72
+ // registered. Removal, appearance and rewrite are all observed immediately.
73
+ //
74
+ // mtimeMs+size alone missed a same-length rewrite that preserved (or restored)
75
+ // the timestamp — exactly the shape of a token refresh written by another
76
+ // process, or of an atomic replace. ctimeMs is bumped by the write itself and
77
+ // cannot be back-dated through utimes, and ino changes on rename-replace, so
78
+ // both join the signature; neither costs an extra syscall.
79
+ function credentialPathsSignature(paths, extra = '') {
80
+ const parts = [];
81
+ for (const path of paths) {
82
+ if (!path) continue;
83
+ try {
84
+ const st = statSync(path);
85
+ parts.push(`${path}:${st.mtimeMs}:${st.ctimeMs}:${st.size}:${st.ino}`);
86
+ } catch {
87
+ // Missing (the logout case) or unstattable — both distinct from a
88
+ // readable file, and both must invalidate a cached answer.
89
+ parts.push(`${path}:-`);
90
+ }
91
+ }
92
+ if (extra) parts.push(extra);
93
+ return parts.join('|');
94
+ }
95
+
96
+ function memoProbe(key, paths, compute, extraSignature = '') {
97
+ const signature = credentialPathsSignature(paths, extraSignature);
27
98
  const hit = _probeCache.get(key);
28
99
  const now = Date.now();
29
- if (hit && now - hit.ts < PROBE_TTL_MS) return hit.value;
100
+ if (hit && hit.signature === signature && now - hit.ts < PROBE_TTL_MS) return hit.value;
30
101
  const value = compute();
31
- _probeCache.set(key, { ts: now, value });
102
+ _probeCache.set(key, { ts: now, signature, value });
32
103
  return value;
33
104
  }
34
105
 
35
- export function hasAnthropicOAuthCredentials() {
36
- return memoProbe('anthropic-oauth', () => {
37
- const paths = [];
38
- const candidates = [];
39
- pushUnique(paths, process.env.ANTHROPIC_OAUTH_CREDENTIALS_PATH);
40
- pushUnique(paths, ANTHROPIC_DEFAULT_CREDENTIALS_PATH);
41
- for (const path of paths) {
42
- const raw = readJsonIfExists(path);
106
+ function anthropicOAuthState() {
107
+ const paths = [];
108
+ pushUnique(paths, process.env.ANTHROPIC_OAUTH_CREDENTIALS_PATH);
109
+ pushUnique(paths, ANTHROPIC_DEFAULT_CREDENTIALS_PATH);
110
+ return memoProbe('anthropic-oauth', paths, () => {
111
+ return resolveProbeState(paths, (docs) => {
112
+ const candidates = [];
113
+ for (const raw of docs) {
43
114
  const oauth = raw?.claudeAiOauth;
44
115
  if (oauth?.accessToken) {
45
- candidates.push({
46
- accessToken: oauth.accessToken,
47
- refreshToken: oauth.refreshToken || null,
48
- expiresAt: Number(oauth.expiresAt ?? oauth.expires_at) || 0,
49
- scopes: Array.isArray(oauth.scopes) ? oauth.scopes : [],
50
- });
116
+ candidates.push({
117
+ accessToken: oauth.accessToken,
118
+ refreshToken: oauth.refreshToken || null,
119
+ expiresAt: Number(oauth.expiresAt ?? oauth.expires_at) || 0,
120
+ scopes: Array.isArray(oauth.scopes) ? oauth.scopes : [],
121
+ });
51
122
  }
52
- }
53
- if (!candidates.length) return false;
54
- candidates.sort((a, b) => (Number(b.expiresAt) || 0) - (Number(a.expiresAt) || 0));
55
- const chosen = candidates[0];
56
- const hasInferenceScope = Array.isArray(chosen.scopes) && chosen.scopes.includes('user:inference');
57
- const expiresAt = Number(chosen.expiresAt) || 0;
58
- return !!(chosen.accessToken && hasInferenceScope
59
- && (expiresAt === 0 || expiresAt > Date.now() || chosen.refreshToken));
123
+ }
124
+ if (!candidates.length) return false;
125
+ candidates.sort((a, b) => (Number(b.expiresAt) || 0) - (Number(a.expiresAt) || 0));
126
+ const chosen = candidates[0];
127
+ const hasInferenceScope = Array.isArray(chosen.scopes) && chosen.scopes.includes('user:inference');
128
+ const expiresAt = Number(chosen.expiresAt) || 0;
129
+ return !!(chosen.accessToken && hasInferenceScope
130
+ && (expiresAt === 0 || expiresAt > Date.now() || chosen.refreshToken));
131
+ });
60
132
  });
61
133
  }
62
134
 
63
- export function hasOpenAIOAuthCredentials() {
64
- return memoProbe('openai-oauth', () => {
65
- const paths = [
66
- process.env.OPENAI_OAUTH_CREDENTIALS_PATH,
67
- join(resolvePluginData(), 'openai-oauth.json'),
68
- ].filter(Boolean);
69
- for (const path of paths) {
70
- const raw = readJsonIfExists(path);
71
- if (raw?.access_token && raw?.refresh_token) return true;
72
- }
73
- return false;
74
- });
135
+ function openAIOAuthState() {
136
+ const paths = [
137
+ process.env.OPENAI_OAUTH_CREDENTIALS_PATH,
138
+ join(resolvePluginData(), 'openai-oauth.json'),
139
+ ];
140
+ return memoProbe('openai-oauth', paths, () => resolveProbeState(
141
+ paths,
142
+ (docs) => docs.some((raw) => !!(raw?.access_token && raw?.refresh_token)),
143
+ ));
75
144
  }
76
145
 
77
- export function hasGrokOAuthCredentials() {
78
- return memoProbe('grok-oauth', () => {
79
- const paths = [
80
- process.env.GROK_OAUTH_CREDENTIALS_PATH,
81
- join(resolvePluginData(), 'grok-oauth.json'),
82
- ].filter(Boolean);
83
- return paths.some((path) => {
84
- const own = readJsonIfExists(path);
85
- return !!(own?.access_token && own?.refresh_token);
86
- });
87
- });
146
+ function grokOAuthState() {
147
+ const paths = [
148
+ process.env.GROK_OAUTH_CREDENTIALS_PATH,
149
+ join(resolvePluginData(), 'grok-oauth.json'),
150
+ ];
151
+ return memoProbe('grok-oauth', paths, () => resolveProbeState(
152
+ paths,
153
+ (docs) => docs.some((own) => !!(own?.access_token && own?.refresh_token)),
154
+ ));
88
155
  }
89
156
 
90
157
  // Antigravity needs a resolved Cloud project alongside the tokens: without it
91
158
  // every request 400s, so a half-finished login must not enable the provider.
92
- export function hasAntigravityOAuthCredentials() {
93
- return memoProbe('antigravity-oauth', () => {
94
- const paths = [
95
- process.env.ANTIGRAVITY_OAUTH_CREDENTIALS_PATH,
96
- join(resolvePluginData(), 'antigravity-oauth.json'),
97
- ].filter(Boolean);
98
- return paths.some((path) => {
99
- const own = readJsonIfExists(path);
100
- return !!(own?.access_token && own?.refresh_token && own?.project_id);
101
- });
102
- });
159
+ function antigravityOAuthState() {
160
+ const paths = [
161
+ process.env.ANTIGRAVITY_OAUTH_CREDENTIALS_PATH,
162
+ join(resolvePluginData(), 'antigravity-oauth.json'),
163
+ ];
164
+ return memoProbe('antigravity-oauth', paths, () => resolveProbeState(
165
+ paths,
166
+ (docs) => docs.some((own) => !!(own?.access_token && own?.refresh_token && own?.project_id)),
167
+ ));
168
+ }
169
+
170
+ // Content fingerprint of CURSOR_ACCESS_TOKEN (never the token itself).
171
+ // '-' means unset, which must stay distinguishable from any hash so that
172
+ // deleting the variable invalidates the memo immediately.
173
+ function cursorEnvTokenFingerprint() {
174
+ const token = process.env.CURSOR_ACCESS_TOKEN;
175
+ if (!token) return '-';
176
+ return createHash('sha256').update(String(token)).digest('base64url').slice(0, 22);
103
177
  }
104
178
 
105
- export function hasCursorOAuthCredentials() {
106
- return memoProbe('cursor-oauth', () => {
179
+ function cursorOAuthState() {
180
+ const paths = [
181
+ process.env.CURSOR_OAUTH_CREDENTIALS_PATH,
182
+ join(resolvePluginData(), 'cursor-oauth.json'),
183
+ ];
184
+ // The env token is the whole answer on this branch and has no file metadata
185
+ // behind it, so its VALUE is what the memo signature has to track: a length
186
+ // (or a bare present/absent flag) let a swap to an equal-length expired token
187
+ // keep serving the stale 'present' for up to PROBE_TTL_MS. Hashed rather than
188
+ // embedded so the memo never holds a second copy of the raw credential.
189
+ const envSignature = `env:${cursorEnvTokenFingerprint()}`;
190
+ return memoProbe('cursor-oauth', paths, () => {
191
+ // An env-provided token needs no file read, so it is never "unreadable".
192
+ // Expiry is read by the one shared JWT reader (cursor-auth.cursorTokenExpiry),
193
+ // which answers 0 for a non-JWT or unparseable token — exactly the "assume
194
+ // usable" outcome this branch already took for those shapes.
107
195
  if (process.env.CURSOR_ACCESS_TOKEN) {
108
- const parts = String(process.env.CURSOR_ACCESS_TOKEN).split('.');
109
- if (parts.length !== 3 || !parts[1]) return true;
110
- try {
111
- const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
112
- const expiresAt = Number(payload?.exp) > 0 ? Number(payload.exp) * 1000 : 0;
113
- return expiresAt === 0 || expiresAt > Date.now();
114
- } catch {
115
- return true;
116
- }
196
+ const expiresAt = cursorTokenExpiry(process.env.CURSOR_ACCESS_TOKEN);
197
+ return (expiresAt === 0 || expiresAt > Date.now()) ? 'present' : 'absent';
117
198
  }
118
- const paths = [
119
- process.env.CURSOR_OAUTH_CREDENTIALS_PATH,
120
- join(resolvePluginData(), 'cursor-oauth.json'),
121
- ].filter(Boolean);
122
- return paths.some((path) => {
123
- const own = readJsonIfExists(path);
199
+ return resolveProbeState(paths, (docs) => docs.some((own) => {
124
200
  if (!own?.access_token) return false;
125
201
  const expiresAt = Number(own.expires_at) || 0;
126
202
  return expiresAt === 0 || expiresAt > Date.now() || Boolean(own.refresh_token);
127
- });
128
- });
203
+ }));
204
+ }, envSignature);
129
205
  }
206
+
207
+ const OAUTH_PROBE_STATES = new Map([
208
+ ['anthropic-oauth', anthropicOAuthState],
209
+ ['openai-oauth', openAIOAuthState],
210
+ ['grok-oauth', grokOAuthState],
211
+ ['cursor-oauth', cursorOAuthState],
212
+ ['antigravity-oauth', antigravityOAuthState],
213
+ ]);
214
+
215
+ /**
216
+ * Tri-state credential probe: 'present' | 'absent' | 'unreadable'.
217
+ *
218
+ * Callers that must tell a deliberate logout ('absent') from a momentary FS
219
+ * failure ('unreadable') use this; `has*OAuthCredentials()` remains the plain
220
+ * "can we use it right now" boolean and is exactly `state === 'present'`.
221
+ */
222
+ export function oauthCredentialProbeState(name) {
223
+ const probe = OAUTH_PROBE_STATES.get(String(name || ''));
224
+ if (!probe) return 'absent';
225
+ try { return probe(); } catch { return 'unreadable'; }
226
+ }
227
+
228
+ export function hasAnthropicOAuthCredentials() { return anthropicOAuthState() === 'present'; }
229
+ export function hasOpenAIOAuthCredentials() { return openAIOAuthState() === 'present'; }
230
+ export function hasGrokOAuthCredentials() { return grokOAuthState() === 'present'; }
231
+ export function hasAntigravityOAuthCredentials() { return antigravityOAuthState() === 'present'; }
232
+ export function hasCursorOAuthCredentials() { return cursorOAuthState() === 'present'; }