@iislee/opencodex 2.11.0 → 2.35.0

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 (733) hide show
  1. package/AGENTS_INSTALL.md +109 -0
  2. package/README.md +117 -22
  3. package/bin/ocx.mjs +245 -88
  4. package/bin/package-main.mjs +1 -1
  5. package/gui/dist/assets/index-Be1sDpWw.css +1 -0
  6. package/gui/dist/assets/index-D4OKgUV4.js +114 -0
  7. package/gui/dist/index.html +2 -2
  8. package/gui/dist/provider-icons/alibaba-color.svg +1 -1
  9. package/gui/dist/provider-icons/antigravity-color.svg +1 -1
  10. package/gui/dist/provider-icons/claude-color.svg +1 -1
  11. package/gui/dist/provider-icons/cline-color.svg +16 -0
  12. package/gui/dist/provider-icons/cloudflare-ai-gateway-color.svg +1 -1
  13. package/gui/dist/provider-icons/commandcode-color.svg +1 -0
  14. package/gui/dist/provider-icons/copilot-color.svg +1 -1
  15. package/gui/dist/provider-icons/cursor-color.svg +1 -1
  16. package/gui/dist/provider-icons/deepseek-color.svg +1 -1
  17. package/gui/dist/provider-icons/firepass-color.svg +1 -1
  18. package/gui/dist/provider-icons/fireworks-color.svg +1 -1
  19. package/gui/dist/provider-icons/gemini-color.svg +1 -1
  20. package/gui/dist/provider-icons/github-copilot-color.svg +1 -1
  21. package/gui/dist/provider-icons/gitlab-duo-color.svg +1 -1
  22. package/gui/dist/provider-icons/grok.svg +1 -1
  23. package/gui/dist/provider-icons/groq-color.svg +1 -1
  24. package/gui/dist/provider-icons/huggingface-color.svg +1 -1
  25. package/gui/dist/provider-icons/kimi-color.svg +1 -1
  26. package/gui/dist/provider-icons/kiro-color.svg +2 -2
  27. package/gui/dist/provider-icons/lm-studio-color.svg +1 -1
  28. package/gui/dist/provider-icons/mistral-color.svg +1 -1
  29. package/gui/dist/provider-icons/moonshot-color.svg +1 -1
  30. package/gui/dist/provider-icons/nvidia-color.svg +1 -1
  31. package/gui/dist/provider-icons/ollama-color.svg +1 -1
  32. package/gui/dist/provider-icons/openai.svg +1 -1
  33. package/gui/dist/provider-icons/opencode.svg +2 -1
  34. package/gui/dist/provider-icons/openrouter-color.svg +1 -1
  35. package/gui/dist/provider-icons/pi.svg +2 -2
  36. package/gui/dist/provider-icons/qianfan-color.svg +1 -1
  37. package/gui/dist/provider-icons/qwen-portal-color.svg +1 -1
  38. package/gui/dist/provider-icons/vercel-ai-gateway-color.svg +1 -1
  39. package/gui/dist/provider-icons/vllm-color.svg +1 -1
  40. package/gui/dist/provider-icons/xiaomi-color.svg +1 -1
  41. package/package.json +21 -10
  42. package/src/adapters/anthropic-output-schema.ts +137 -0
  43. package/src/adapters/anthropic.ts +475 -62
  44. package/src/adapters/base.ts +82 -7
  45. package/src/adapters/client-fingerprint.ts +18 -12
  46. package/src/adapters/cline-pass-deepseek-v4-tool-replay.ts +69 -0
  47. package/src/adapters/command-code.ts +637 -0
  48. package/src/adapters/cursor/call-id.ts +44 -0
  49. package/src/adapters/cursor/catalog.ts +541 -0
  50. package/src/adapters/cursor/checkpoint-store.ts +308 -0
  51. package/src/adapters/cursor/cursor-errors.ts +144 -6
  52. package/src/adapters/cursor/discovery.ts +122 -14
  53. package/src/adapters/cursor/effort-map.ts +106 -4
  54. package/src/adapters/cursor/envelope-echo.ts +290 -0
  55. package/src/adapters/cursor/framing.ts +39 -0
  56. package/src/adapters/cursor/h2-pool.ts +123 -0
  57. package/src/adapters/cursor/http1-bidi.ts +361 -0
  58. package/src/adapters/cursor/images.ts +704 -0
  59. package/src/adapters/cursor/live-models.ts +180 -59
  60. package/src/adapters/cursor/live-transport.ts +623 -170
  61. package/src/adapters/cursor/message-mapper.ts +4 -1
  62. package/src/adapters/cursor/native-exec-common.ts +23 -2
  63. package/src/adapters/cursor/native-exec-desktop.ts +23 -0
  64. package/src/adapters/cursor/native-exec-fs.ts +10 -7
  65. package/src/adapters/cursor/native-exec-network.ts +1 -1
  66. package/src/adapters/cursor/native-exec-shell.ts +5 -3
  67. package/src/adapters/cursor/native-exec.ts +121 -14
  68. package/src/adapters/cursor/protobuf-events.ts +829 -11
  69. package/src/adapters/cursor/protobuf-request.ts +447 -75
  70. package/src/adapters/cursor/request-builder.ts +271 -35
  71. package/src/adapters/cursor/tool-definitions.ts +252 -12
  72. package/src/adapters/cursor/tool-result-normalize.ts +115 -0
  73. package/src/adapters/cursor/transport.ts +22 -0
  74. package/src/adapters/cursor/types.ts +48 -1
  75. package/src/adapters/cursor.ts +336 -39
  76. package/src/adapters/exec-tool-result-normalize.ts +99 -0
  77. package/src/adapters/google-antigravity-replay.ts +713 -41
  78. package/src/adapters/google-antigravity-wire.ts +48 -10
  79. package/src/adapters/google-errors.ts +44 -12
  80. package/src/adapters/google-http.ts +38 -10
  81. package/src/adapters/google-tool-schema.ts +80 -15
  82. package/src/adapters/google-truncation.ts +11 -0
  83. package/src/adapters/google.ts +742 -86
  84. package/src/adapters/identity.ts +39 -6
  85. package/src/adapters/image.ts +1 -1
  86. package/src/adapters/kiro-constants.ts +27 -0
  87. package/src/adapters/kiro-errors.ts +11 -0
  88. package/src/adapters/kiro-events.ts +19 -1
  89. package/src/adapters/kiro-thinking.ts +18 -2
  90. package/src/adapters/kiro-tools.ts +65 -17
  91. package/src/adapters/kiro.ts +270 -73
  92. package/src/adapters/mimo-free.ts +17 -0
  93. package/src/adapters/openai-chat-url.ts +11 -0
  94. package/src/adapters/openai-chat.ts +1377 -344
  95. package/src/adapters/openai-responses-url.ts +16 -0
  96. package/src/adapters/openai-responses.ts +1164 -64
  97. package/src/adapters/registry.ts +175 -0
  98. package/src/adapters/responses-tool-schema.ts +67 -0
  99. package/src/adapters/run-turn-queue.ts +36 -1
  100. package/src/adapters/tool-call-id.ts +119 -0
  101. package/src/adapters/tool-catalog-nudge.ts +105 -21
  102. package/src/adapters/xai-tool-schema.ts +436 -0
  103. package/src/adapters/xai-web-search.ts +186 -0
  104. package/src/bridge.ts +512 -92
  105. package/src/chat/inbound.ts +57 -20
  106. package/src/chat/outbound.ts +97 -33
  107. package/src/claude/agents-inject.ts +40 -10
  108. package/src/claude/context-windows.ts +37 -14
  109. package/src/claude/desktop-3p.ts +243 -9
  110. package/src/claude/gateway-cache.ts +41 -4
  111. package/src/claude/inbound.ts +72 -3
  112. package/src/claude/model-info.ts +38 -15
  113. package/src/claude/outbound.ts +88 -21
  114. package/src/cli/access.ts +46 -3
  115. package/src/cli/account-api.ts +150 -19
  116. package/src/cli/account-auth.ts +33 -6
  117. package/src/cli/account-catalog-refresh.ts +14 -0
  118. package/src/cli/account-extended.ts +648 -28
  119. package/src/cli/account-main.ts +317 -0
  120. package/src/cli/account.ts +97 -19
  121. package/src/cli/agent.ts +53 -2
  122. package/src/cli/alias.ts +66 -0
  123. package/src/cli/capabilities-command.ts +94 -0
  124. package/src/cli/capabilities.ts +496 -0
  125. package/src/cli/claude-agent-startup-sync.ts +73 -0
  126. package/src/cli/claude-desktop.ts +60 -15
  127. package/src/cli/claude.ts +129 -35
  128. package/src/cli/codex-log-guard-doctor.ts +103 -0
  129. package/src/cli/codex-shim-autorestore.ts +2 -0
  130. package/src/cli/codex-shim-readiness.ts +76 -0
  131. package/src/cli/combo.ts +8 -0
  132. package/src/cli/config-command.ts +74 -10
  133. package/src/cli/dispatch.ts +821 -0
  134. package/src/cli/doctor.ts +440 -56
  135. package/src/cli/ensure-desired-integrations.ts +152 -0
  136. package/src/cli/export-command.ts +46 -20
  137. package/src/cli/help.ts +34 -274
  138. package/src/cli/index.ts +417 -528
  139. package/src/cli/init.ts +5 -17
  140. package/src/cli/inspect.ts +230 -0
  141. package/src/cli/integrations.ts +120 -2
  142. package/src/cli/lab.ts +607 -0
  143. package/src/cli/launcher-context.ts +77 -0
  144. package/src/cli/minimax.ts +497 -0
  145. package/src/cli/models-runtime.ts +130 -2
  146. package/src/cli/models.ts +118 -16
  147. package/src/cli/observe.ts +123 -11
  148. package/src/cli/opencode.ts +4 -2
  149. package/src/cli/provider-runtime.ts +34 -3
  150. package/src/cli/provider.ts +25 -3
  151. package/src/cli/ready.ts +301 -0
  152. package/src/cli/registry.ts +476 -0
  153. package/src/cli/root.ts +86 -0
  154. package/src/cli/route-policy.ts +92 -0
  155. package/src/cli/runtime-api.ts +57 -10
  156. package/src/cli/star-prompt.ts +71 -15
  157. package/src/cli/status.ts +51 -4
  158. package/src/cli/storage.ts +234 -0
  159. package/src/cli/system-command.ts +16 -0
  160. package/src/cli/system-restart-client.ts +146 -0
  161. package/src/cli/tray-proxy.ts +153 -6
  162. package/src/cli/usage-report.ts +184 -0
  163. package/src/cli/v2.ts +137 -18
  164. package/src/cli/version-skew.ts +46 -0
  165. package/src/cli.ts +1 -1
  166. package/src/clients/config-export.ts +1442 -23
  167. package/src/clients/effective-status.ts +30 -2
  168. package/src/clients/probes/cline.ts +81 -0
  169. package/src/codex/account-label.ts +35 -1
  170. package/src/codex/account-lifecycle.ts +130 -13
  171. package/src/codex/account-namespaces.ts +49 -3
  172. package/src/codex/account-pause.ts +2 -1
  173. package/src/codex/account-priority.ts +84 -0
  174. package/src/codex/account-store.ts +29 -2
  175. package/src/codex/account-usability.ts +25 -2
  176. package/src/codex/admission.ts +256 -0
  177. package/src/codex/affinity-debug.ts +162 -0
  178. package/src/codex/app-server-processes.ts +570 -107
  179. package/src/codex/app-server-restart-service.ts +232 -0
  180. package/src/codex/auth-api.ts +893 -246
  181. package/src/codex/auth-collision.ts +5 -3
  182. package/src/codex/auth-context.ts +348 -32
  183. package/src/codex/autostart-health.ts +24 -1
  184. package/src/codex/catalog/account-models.ts +67 -0
  185. package/src/codex/catalog/aggregation.ts +86 -10
  186. package/src/codex/catalog/bundled.ts +331 -33
  187. package/src/codex/catalog/effort.ts +138 -32
  188. package/src/codex/catalog/filesystem-evidence.ts +302 -0
  189. package/src/codex/catalog/kinds.ts +2 -0
  190. package/src/codex/catalog/metadata.ts +555 -45
  191. package/src/codex/catalog/native-models.ts +75 -0
  192. package/src/codex/catalog/parsing.ts +269 -30
  193. package/src/codex/catalog/provider-fetch.ts +1626 -144
  194. package/src/codex/catalog/sync.ts +1576 -187
  195. package/src/codex/catalog-admission.ts +199 -0
  196. package/src/codex/catalog-refresh-status.ts +105 -0
  197. package/src/codex/catalog-write-serialization.ts +242 -0
  198. package/src/codex/catalog.ts +6 -3
  199. package/src/codex/codex-write-lock.ts +386 -0
  200. package/src/codex/convergence-types.ts +614 -0
  201. package/src/codex/convergence.ts +676 -0
  202. package/src/codex/coordinator-doctor.ts +332 -0
  203. package/src/codex/custom-model-catalog-migration.ts +176 -0
  204. package/src/codex/desired-state.ts +230 -0
  205. package/src/codex/desktop-app-restart.ts +355 -0
  206. package/src/codex/features.ts +636 -39
  207. package/src/codex/generation.ts +202 -0
  208. package/src/codex/history-job.ts +436 -0
  209. package/src/codex/history-lock.ts +242 -0
  210. package/src/codex/history-manifest.ts +112 -0
  211. package/src/codex/history-migration-guardian.ts +30 -24
  212. package/src/codex/history-provider.ts +1016 -235
  213. package/src/codex/history-transition.ts +105 -0
  214. package/src/codex/history-worker.ts +223 -0
  215. package/src/codex/inject-coordination.ts +373 -0
  216. package/src/codex/inject.ts +1114 -152
  217. package/src/codex/injected-marker.ts +37 -3
  218. package/src/codex/integration-record.ts +266 -0
  219. package/src/codex/internal/catalog-writer.ts +203 -0
  220. package/src/codex/internal/history-writer.ts +81 -0
  221. package/src/codex/journal.ts +66 -4
  222. package/src/codex/log-guard/inspect.ts +524 -0
  223. package/src/codex/log-guard/lock.ts +150 -0
  224. package/src/codex/log-guard/maintenance.ts +403 -0
  225. package/src/codex/log-guard/path-safety.ts +88 -0
  226. package/src/codex/log-guard/policy.ts +44 -0
  227. package/src/codex/log-guard/processes.ts +205 -0
  228. package/src/codex/log-guard/protection.ts +489 -0
  229. package/src/codex/log-guard/sqlite-errors.ts +9 -0
  230. package/src/codex/main-account-cache.ts +24 -0
  231. package/src/codex/main-account.ts +29 -1
  232. package/src/codex/management-convergence.ts +167 -0
  233. package/src/codex/model-cache.ts +56 -10
  234. package/src/codex/model-entitlements.ts +360 -0
  235. package/src/codex/native-main-admission.ts +47 -0
  236. package/src/codex/native-main-auth-temp.ts +187 -0
  237. package/src/codex/native-main-claim.ts +178 -0
  238. package/src/codex/native-main-lock-file.ts +162 -0
  239. package/src/codex/native-main-owner.ts +329 -0
  240. package/src/codex/native-profile-api.ts +247 -0
  241. package/src/codex/native-profile-manager.ts +1531 -0
  242. package/src/codex/native-profile-processes.ts +121 -0
  243. package/src/codex/native-profile-recovery.ts +99 -0
  244. package/src/codex/native-profile-stage-store.ts +387 -0
  245. package/src/codex/native-profile-startup.ts +622 -0
  246. package/src/codex/native-profile-store.ts +855 -0
  247. package/src/codex/native-profile-types.ts +120 -0
  248. package/src/codex/native-residue.ts +675 -0
  249. package/src/codex/paths.ts +80 -1
  250. package/src/codex/plan-from-token.ts +140 -0
  251. package/src/codex/plan.ts +40 -0
  252. package/src/codex/plugins-doctor.ts +1 -1
  253. package/src/codex/pool-rotation.ts +74 -4
  254. package/src/codex/project-config-warnings.ts +20 -6
  255. package/src/codex/prompt-journal.ts +352 -0
  256. package/src/codex/prompt-layers.ts +1313 -0
  257. package/src/codex/prompt-lock.ts +143 -0
  258. package/src/codex/prompt-text-probe.ts +238 -0
  259. package/src/codex/quota-rejection.ts +298 -0
  260. package/src/codex/quota.ts +264 -21
  261. package/src/codex/refresh.ts +11 -2
  262. package/src/codex/reset-credit-recovery.ts +1044 -0
  263. package/src/codex/routing.ts +514 -94
  264. package/src/codex/runtime.ts +159 -38
  265. package/src/codex/shim.ts +1065 -31
  266. package/src/codex/subagent-model-fallback.ts +437 -43
  267. package/src/codex/sync.ts +191 -2
  268. package/src/codex/transition-state.ts +720 -0
  269. package/src/codex/upstream-host-health.ts +368 -0
  270. package/src/codex/user-identity.ts +557 -0
  271. package/src/codex/warmup.ts +187 -81
  272. package/src/codex/write-coordination.ts +114 -0
  273. package/src/combos/failover.ts +47 -0
  274. package/src/combos/index.ts +4 -0
  275. package/src/combos/request.ts +32 -0
  276. package/src/combos/types.ts +91 -9
  277. package/src/compatibility/index.ts +26 -0
  278. package/src/compatibility/manifest.ts +253 -0
  279. package/src/compatibility/openai-responses.ts +121 -0
  280. package/src/config/atomic-write.ts +219 -0
  281. package/src/config/paths.ts +40 -0
  282. package/src/config/process-state.ts +309 -0
  283. package/src/config/provider-name.ts +24 -0
  284. package/src/config/provider-validation.ts +177 -0
  285. package/src/config/rebase-provenance.ts +68 -0
  286. package/src/config.ts +1713 -709
  287. package/src/generated/compatibility-version.json +3324 -0
  288. package/src/generated/{jawcode-model-metadata.ts → model-metadata.ts} +19 -17
  289. package/src/grok/inject.ts +16 -5
  290. package/src/grok/inspect.ts +45 -0
  291. package/src/grok/sync.ts +2 -2
  292. package/src/images/loop.ts +157 -33
  293. package/src/images/plan.ts +24 -13
  294. package/src/integrations/config-io.ts +269 -0
  295. package/src/integrations/journal.ts +315 -0
  296. package/src/integrations/merge.ts +135 -0
  297. package/src/integrations/mutation-flight.ts +71 -0
  298. package/src/integrations/native/ownership-preflight.ts +202 -0
  299. package/src/integrations/omp-yaml-source.ts +358 -0
  300. package/src/integrations/owned-refresh.ts +74 -0
  301. package/src/integrations/ownership-policy.ts +160 -0
  302. package/src/integrations/ownership.ts +155 -0
  303. package/src/integrations/registry.ts +166 -0
  304. package/src/integrations/serialize.ts +314 -0
  305. package/src/integrations/state.ts +433 -0
  306. package/src/integrations/store.ts +103 -0
  307. package/src/integrations/writer-lock.ts +98 -0
  308. package/src/integrations/writer.ts +715 -0
  309. package/src/lab/artifacts/sanitize.ts +586 -0
  310. package/src/lab/artifacts/secure-fs.ts +475 -0
  311. package/src/lab/artifacts/store.ts +310 -0
  312. package/src/lab/automation/budgets.ts +78 -0
  313. package/src/lab/automation/config-persistence.ts +256 -0
  314. package/src/lab/automation/constants.ts +39 -0
  315. package/src/lab/automation/cooldown.ts +103 -0
  316. package/src/lab/automation/dispatch.ts +211 -0
  317. package/src/lab/automation/index.ts +13 -0
  318. package/src/lab/automation/orchestrator.ts +499 -0
  319. package/src/lab/automation/persistence.ts +512 -0
  320. package/src/lab/automation/planner.ts +371 -0
  321. package/src/lab/automation/policy.ts +136 -0
  322. package/src/lab/automation/queue.ts +191 -0
  323. package/src/lab/automation/recovery.ts +24 -0
  324. package/src/lab/automation/route-context.ts +21 -0
  325. package/src/lab/automation/run-key.ts +44 -0
  326. package/src/lab/automation/runs-query.ts +34 -0
  327. package/src/lab/automation/types.ts +160 -0
  328. package/src/lab/conformance/assertion.ts +325 -0
  329. package/src/lab/conformance/digest.ts +22 -0
  330. package/src/lab/conformance/executor.ts +741 -0
  331. package/src/lab/conformance/fixture-provider.ts +27 -0
  332. package/src/lab/conformance/fixtures/live-v1-cases.json +175 -0
  333. package/src/lab/conformance/fixtures/protocol-v1-cases.json +461 -0
  334. package/src/lab/conformance/harness-budget.ts +47 -0
  335. package/src/lab/conformance/index.ts +5 -0
  336. package/src/lab/conformance/jcs.ts +64 -0
  337. package/src/lab/conformance/json-pointer.ts +39 -0
  338. package/src/lab/conformance/manifest.ts +180 -0
  339. package/src/lab/conformance/mcp-stub.ts +179 -0
  340. package/src/lab/conformance/negative-controls.ts +164 -0
  341. package/src/lab/conformance/observation.ts +355 -0
  342. package/src/lab/conformance/runner.ts +68 -0
  343. package/src/lab/conformance/sse-normalize.ts +59 -0
  344. package/src/lab/conformance/suite-manifest.ts +78 -0
  345. package/src/lab/conformance/types.ts +214 -0
  346. package/src/lab/constants.ts +126 -0
  347. package/src/lab/digest.ts +64 -0
  348. package/src/lab/events/errors.ts +9 -0
  349. package/src/lab/events/limits.ts +117 -0
  350. package/src/lab/events/types.ts +229 -0
  351. package/src/lab/events/validate.ts +781 -0
  352. package/src/lab/fabric/constants.ts +40 -0
  353. package/src/lab/fabric/executor.ts +492 -0
  354. package/src/lab/fabric/index.ts +80 -0
  355. package/src/lab/fabric/manifest.ts +222 -0
  356. package/src/lab/fabric/observe.ts +489 -0
  357. package/src/lab/fabric/patch.ts +79 -0
  358. package/src/lab/fabric/producer-child.ts +139 -0
  359. package/src/lab/fabric/producer-isolate.ts +276 -0
  360. package/src/lab/fabric/producer-protocol.ts +61 -0
  361. package/src/lab/fabric/scratch.ts +439 -0
  362. package/src/lab/fabric/subject.ts +106 -0
  363. package/src/lab/fabric/types.ts +134 -0
  364. package/src/lab/fabric/verifier.ts +98 -0
  365. package/src/lab/index.ts +54 -0
  366. package/src/lab/ledger/artifact-refs.ts +127 -0
  367. package/src/lab/ledger/invalidation.ts +136 -0
  368. package/src/lab/ledger/purge.ts +310 -0
  369. package/src/lab/ledger/store.ts +532 -0
  370. package/src/lab/live/credential-lease.ts +53 -0
  371. package/src/lab/live/destination.ts +155 -0
  372. package/src/lab/live/executor.ts +336 -0
  373. package/src/lab/live/inert-tools.ts +56 -0
  374. package/src/lab/live/manifest.ts +85 -0
  375. package/src/lab/live/mcp-loopback.ts +57 -0
  376. package/src/lab/live/runner.ts +19 -0
  377. package/src/lab/live/sandbox.ts +61 -0
  378. package/src/lab/live/suite-manifest.ts +41 -0
  379. package/src/lab/live/transport.ts +118 -0
  380. package/src/lab/live/types.ts +197 -0
  381. package/src/lab/observe/from-conformance.ts +301 -0
  382. package/src/lab/observe/from-live.ts +117 -0
  383. package/src/lab/paths.ts +153 -0
  384. package/src/lab/projection/rebuild.ts +495 -0
  385. package/src/lab/projection/schema.ts +135 -0
  386. package/src/lab/projection/verdicts.ts +474 -0
  387. package/src/lab/projection/verification.ts +412 -0
  388. package/src/lab/public/bundle.ts +217 -0
  389. package/src/lab/public/community-authority.ts +175 -0
  390. package/src/lab/public/community-files.ts +29 -0
  391. package/src/lab/public/community.ts +479 -0
  392. package/src/lab/public/file-safety.ts +155 -0
  393. package/src/lab/public/ids.ts +26 -0
  394. package/src/lab/public/index.ts +16 -0
  395. package/src/lab/public/mutation-lock.ts +424 -0
  396. package/src/lab/public/operator.ts +353 -0
  397. package/src/lab/public/origin-purge.ts +79 -0
  398. package/src/lab/public/origin.ts +203 -0
  399. package/src/lab/public/privacy.ts +143 -0
  400. package/src/lab/public/private-file.ts +261 -0
  401. package/src/lab/public/project.ts +124 -0
  402. package/src/lab/public/purge-test-fault.ts +21 -0
  403. package/src/lab/public/purge.ts +223 -0
  404. package/src/lab/public/registry.ts +44 -0
  405. package/src/lab/public/revocation.ts +252 -0
  406. package/src/lab/public/signature.ts +243 -0
  407. package/src/lab/public/storage.ts +105 -0
  408. package/src/lab/public/strict-json.ts +206 -0
  409. package/src/lab/public/time.ts +26 -0
  410. package/src/lab/public/types.ts +172 -0
  411. package/src/lab/public/validate.ts +391 -0
  412. package/src/lab/query/catalog.ts +101 -0
  413. package/src/lab/query/connection.ts +107 -0
  414. package/src/lab/query/constants.ts +4 -0
  415. package/src/lab/query/cursor.ts +132 -0
  416. package/src/lab/query/dto-map.ts +277 -0
  417. package/src/lab/query/errors.ts +22 -0
  418. package/src/lab/query/freshness.ts +53 -0
  419. package/src/lab/query/index.ts +45 -0
  420. package/src/lab/query/latest-observation.ts +59 -0
  421. package/src/lab/query/passive-production.ts +159 -0
  422. package/src/lab/query/queries.ts +444 -0
  423. package/src/lab/query/types.ts +266 -0
  424. package/src/lab/subject/behavior-fingerprint.ts +77 -0
  425. package/src/lab/subject/installation-salt.ts +112 -0
  426. package/src/lab/subject/protocol-subject.ts +80 -0
  427. package/src/lab/subject/route-subject.ts +74 -0
  428. package/src/lib/admin-secrets.ts +24 -0
  429. package/src/lib/app-owned-memory-stores.ts +22 -0
  430. package/src/lib/bounded-body.ts +167 -11
  431. package/src/lib/bun-runtime.ts +125 -12
  432. package/src/lib/bun-stream-caps.ts +13 -9
  433. package/src/lib/codex-restart-contract.ts +120 -0
  434. package/src/lib/config-ownership.ts +26 -2
  435. package/src/lib/destination-policy.ts +65 -1
  436. package/src/lib/errors.ts +80 -5
  437. package/src/lib/fabric-task-execution-authority.ts +7 -0
  438. package/src/lib/fabric-task-host.ts +29 -0
  439. package/src/lib/lab-activation.ts +223 -0
  440. package/src/lib/lab-live-execution-authority.ts +13 -0
  441. package/src/lib/lab-live-host.ts +30 -0
  442. package/src/lib/lab-live-pinned-sender.ts +56 -0
  443. package/src/lib/lab-live-route-production.ts +130 -0
  444. package/src/lib/lab-passive-linker-registration.ts +26 -0
  445. package/src/lib/local-management-attestation.ts +51 -0
  446. package/src/lib/local-management-capability.ts +100 -0
  447. package/src/lib/local-provider-reload-contract.ts +100 -0
  448. package/src/lib/optional-shutdown-hooks.ts +57 -0
  449. package/src/lib/package-tree-integrity.ts +101 -0
  450. package/src/lib/pinned-http.ts +145 -26
  451. package/src/lib/process-control.ts +6 -2
  452. package/src/lib/provider-outbound.ts +49 -9
  453. package/src/lib/redact.ts +419 -3
  454. package/src/lib/self-launch-argv.ts +15 -0
  455. package/src/lib/server-resource-ownership.ts +71 -0
  456. package/src/lib/service-secrets.ts +15 -0
  457. package/src/lib/shadow-call.ts +35 -4
  458. package/src/lib/sse-decoder.ts +41 -0
  459. package/src/lib/state-store-registrations.ts +10 -2
  460. package/src/lib/system-restart-contract.ts +73 -0
  461. package/src/lib/token-estimate.ts +19 -2
  462. package/src/lib/tool-argument-integers.ts +253 -0
  463. package/src/lib/translator-budget.ts +44 -0
  464. package/src/lib/upstream-http-version.ts +57 -0
  465. package/src/lib/upstream-reachability.ts +95 -0
  466. package/src/lib/upstream-retry.ts +156 -3
  467. package/src/lib/windows-atomic-replace.ts +155 -0
  468. package/src/lib/windows-elevation.ts +70 -2
  469. package/src/lib/windows-secret-acl.ts +409 -69
  470. package/src/lib/windows-service-wrappers.ts +72 -0
  471. package/src/lib/windows-text.ts +106 -0
  472. package/src/lib/windows-user-principal.ts +341 -0
  473. package/src/lib/winsw.ts +33 -5
  474. package/src/oauth/account-import/google-antigravity-adapter.ts +74 -0
  475. package/src/oauth/account-import/index.ts +15 -0
  476. package/src/oauth/account-import/parser.ts +83 -0
  477. package/src/oauth/account-import/registry.ts +18 -0
  478. package/src/oauth/account-import/service.ts +75 -0
  479. package/src/oauth/account-import/types.ts +91 -0
  480. package/src/oauth/anthropic.ts +12 -1
  481. package/src/oauth/callback-server.ts +30 -4
  482. package/src/oauth/chatgpt.ts +12 -1
  483. package/src/oauth/cline.ts +203 -0
  484. package/src/oauth/command-code.ts +239 -0
  485. package/src/oauth/cursor.ts +46 -5
  486. package/src/oauth/generic-account-failover.ts +231 -0
  487. package/src/oauth/google-antigravity.ts +35 -3
  488. package/src/oauth/health.ts +20 -12
  489. package/src/oauth/index.ts +462 -71
  490. package/src/oauth/key-providers.ts +25 -0
  491. package/src/oauth/kimi.ts +25 -3
  492. package/src/oauth/kiro.ts +95 -6
  493. package/src/oauth/local-token-detect.ts +13 -2
  494. package/src/oauth/log.ts +3 -1
  495. package/src/oauth/login-cli.ts +88 -28
  496. package/src/oauth/nous.ts +798 -0
  497. package/src/oauth/open-browser-choice.ts +26 -0
  498. package/src/oauth/store.ts +133 -26
  499. package/src/oauth/token-guardian.ts +9 -3
  500. package/src/oauth/types.ts +15 -0
  501. package/src/pi/models.ts +2 -2
  502. package/src/providers/alibaba-region-migration.ts +1 -1
  503. package/src/providers/antigravity-models.ts +521 -31
  504. package/src/providers/auto-compact-budget.ts +65 -0
  505. package/src/providers/base-url-choices.ts +10 -0
  506. package/src/providers/codex-capacity.ts +292 -0
  507. package/src/providers/command-code-efforts.ts +176 -0
  508. package/src/providers/context-cap.ts +26 -8
  509. package/src/providers/cursor-pool.ts +72 -0
  510. package/src/providers/default-aliases.ts +65 -0
  511. package/src/providers/derive.ts +281 -6
  512. package/src/providers/fastwire.ts +507 -0
  513. package/src/providers/free-directory.ts +10 -7
  514. package/src/providers/google-vertex-location.ts +14 -0
  515. package/src/providers/key-failover.ts +71 -3
  516. package/src/providers/label.ts +35 -2
  517. package/src/providers/model-discovery-limits.ts +16 -0
  518. package/src/providers/model-discovery.ts +115 -22
  519. package/src/providers/model-presets.ts +119 -0
  520. package/src/providers/model-rename-migration.ts +255 -0
  521. package/src/providers/model-rename-startup.ts +28 -0
  522. package/src/providers/new-model-policy.ts +146 -0
  523. package/src/providers/openai-sidecar.ts +72 -4
  524. package/src/providers/openai-tier-startup.ts +31 -2
  525. package/src/providers/openai-tiers.ts +119 -4
  526. package/src/providers/openai-virtual-models.ts +1 -0
  527. package/src/providers/opencode-zen-rate-limit.ts +102 -0
  528. package/src/providers/provider-id-rewrite.ts +30 -0
  529. package/src/providers/quota.ts +1379 -40
  530. package/src/providers/registry.ts +1545 -112
  531. package/src/providers/request-pacing.ts +310 -0
  532. package/src/providers/service-tier.ts +277 -0
  533. package/src/providers/slug-codec.ts +94 -6
  534. package/src/providers/static-model-discovery.ts +86 -0
  535. package/src/providers/xai-responses-opt-in.ts +15 -0
  536. package/src/providers/xai-transport.ts +32 -4
  537. package/src/reasoning-effort.ts +68 -3
  538. package/src/responses/apply-patch-envelope.ts +63 -0
  539. package/src/responses/code-mode-helper-compat.ts +50 -0
  540. package/src/responses/compaction.ts +26 -1
  541. package/src/responses/custom-tool-compat.ts +384 -0
  542. package/src/responses/hosted-tool-policy.ts +9 -0
  543. package/src/responses/namespace-tool-compat.ts +435 -0
  544. package/src/responses/parser.ts +225 -38
  545. package/src/responses/provider-continuation.ts +98 -0
  546. package/src/responses/provider-opaque-metadata.ts +73 -0
  547. package/src/responses/reasoning-envelope.ts +9 -1
  548. package/src/responses/reasoning-replay-cache.ts +504 -0
  549. package/src/responses/schema.ts +12 -2
  550. package/src/responses/spill-store.ts +75 -10
  551. package/src/responses/state.ts +659 -31
  552. package/src/responses/thought-signature-replay.ts +364 -0
  553. package/src/responses/tool-search-compat.ts +301 -0
  554. package/src/responses/truncated-stop-reason.ts +60 -0
  555. package/src/responses/turn-termination.ts +107 -0
  556. package/src/router.ts +405 -31
  557. package/src/routing/analytics.ts +379 -0
  558. package/src/routing/capability.ts +244 -0
  559. package/src/routing/compatibility/assemble.ts +73 -0
  560. package/src/routing/compatibility/behavior.ts +278 -0
  561. package/src/routing/compatibility/catalog.ts +99 -0
  562. package/src/routing/compatibility/endpoint.ts +52 -0
  563. package/src/routing/compatibility/lab-evidence-provider.ts +130 -0
  564. package/src/routing/compatibility/policy.ts +181 -0
  565. package/src/routing/compatibility/provider-slot.ts +56 -0
  566. package/src/routing/compatibility/reader.ts +110 -0
  567. package/src/routing/compatibility/subject.ts +191 -0
  568. package/src/routing/compatibility/types.ts +64 -0
  569. package/src/routing/compatibility/version.ts +104 -0
  570. package/src/routing/cost.ts +77 -0
  571. package/src/routing/evaluator.ts +495 -0
  572. package/src/routing/health.ts +412 -0
  573. package/src/routing/history/cursor.ts +43 -0
  574. package/src/routing/history/indexer.ts +605 -0
  575. package/src/routing/history/schema.ts +72 -0
  576. package/src/routing/profile-namespace.ts +15 -0
  577. package/src/routing/profile.ts +547 -0
  578. package/src/routing/quota.ts +155 -0
  579. package/src/routing/request-evidence.ts +45 -0
  580. package/src/routing/trace.ts +776 -0
  581. package/src/server/adapter-resolve.ts +2 -29
  582. package/src/server/auth-cors.ts +289 -48
  583. package/src/server/background-lifecycle.ts +182 -0
  584. package/src/server/chat-completions.ts +154 -72
  585. package/src/server/chat-native-sse.ts +331 -0
  586. package/src/server/chat-native.ts +445 -0
  587. package/src/server/claude-messages.ts +160 -44
  588. package/src/server/direct-local-http.ts +347 -0
  589. package/src/server/effort-policy.ts +18 -0
  590. package/src/server/github-copilot-responses-repair.ts +338 -0
  591. package/src/server/gui-static.ts +39 -10
  592. package/src/server/images.ts +94 -12
  593. package/src/server/index.ts +1021 -190
  594. package/src/server/lifecycle.ts +336 -14
  595. package/src/server/live.ts +136 -17
  596. package/src/server/local-management-read-client.ts +90 -0
  597. package/src/server/local-provider-reload-client.ts +137 -0
  598. package/src/server/management/agent-settings-routes.ts +433 -116
  599. package/src/server/management/api-key-usage.ts +31 -5
  600. package/src/server/management/body.ts +6 -0
  601. package/src/server/management/codex-prompt-routes.ts +570 -0
  602. package/src/server/management/combo-routes.ts +64 -25
  603. package/src/server/management/config-routes.ts +511 -51
  604. package/src/server/management/context.ts +89 -2
  605. package/src/server/management/integration-routes.ts +498 -0
  606. package/src/server/management/lab-automation-routes.ts +206 -0
  607. package/src/server/management/lab-routes.ts +563 -0
  608. package/src/server/management/logs-usage-routes.ts +127 -52
  609. package/src/server/management/model-routes.ts +461 -133
  610. package/src/server/management/model-rows.ts +167 -0
  611. package/src/server/management/native-integration-routes.ts +770 -0
  612. package/src/server/management/oauth-account-routes.ts +90 -8
  613. package/src/server/management/provider-capability-config.ts +48 -0
  614. package/src/server/management/provider-routes.ts +836 -159
  615. package/src/server/management/request-history-routes.ts +191 -0
  616. package/src/server/management/route-registry.ts +332 -0
  617. package/src/server/management/routing-analytics-routes.ts +74 -0
  618. package/src/server/management/routing-profile-routes.ts +380 -0
  619. package/src/server/management/shared.ts +27 -11
  620. package/src/server/management/sidebar-routes.ts +47 -31
  621. package/src/server/management/storage-log-guard-routes.ts +186 -0
  622. package/src/server/management/sync-response.ts +69 -0
  623. package/src/server/management/system-restart.ts +289 -33
  624. package/src/server/management/system-routes.ts +77 -0
  625. package/src/server/management/usage-summary-cache.ts +9 -1
  626. package/src/server/management/vision-sidecar-options.ts +167 -0
  627. package/src/server/management/web-search-sidecar-options.ts +120 -0
  628. package/src/server/management-api.ts +126 -23
  629. package/src/server/management-auth.ts +220 -5
  630. package/src/server/passive-route-linker.ts +66 -0
  631. package/src/server/port-reclaim.ts +1 -1
  632. package/src/server/ports.ts +41 -1
  633. package/src/server/proxy-liveness.ts +161 -10
  634. package/src/server/readiness.ts +99 -0
  635. package/src/server/relay-eager.ts +221 -54
  636. package/src/server/relay.ts +479 -87
  637. package/src/server/request-decompress.ts +113 -6
  638. package/src/server/request-log-conversation.ts +51 -0
  639. package/src/server/request-log.ts +298 -31
  640. package/src/server/responses/agent-task-recovery-cache.ts +143 -0
  641. package/src/server/responses/agent-task-recovery.ts +465 -0
  642. package/src/server/responses/codex-auth-error.ts +55 -0
  643. package/src/server/responses/collaboration.ts +239 -36
  644. package/src/server/responses/combo-stream-preflight.ts +178 -0
  645. package/src/server/responses/compact.ts +444 -72
  646. package/src/server/responses/core.ts +3579 -436
  647. package/src/server/responses/empty-completion-guard.ts +333 -0
  648. package/src/server/responses/encrypted-payload.ts +62 -39
  649. package/src/server/responses/fetch-helpers.ts +89 -99
  650. package/src/server/responses/input-admission.ts +187 -0
  651. package/src/server/responses/pacing-overload.ts +13 -0
  652. package/src/server/responses/passthrough-error.ts +33 -9
  653. package/src/server/responses/policy-fallback.ts +178 -0
  654. package/src/server/responses/responses-field-backfill.ts +343 -0
  655. package/src/server/responses/terminal-guard.ts +26 -5
  656. package/src/server/responses/upstream-error.ts +5 -0
  657. package/src/server/responses/ws-upstream.ts +381 -0
  658. package/src/server/responses-custom-tool-repair.ts +363 -0
  659. package/src/server/responses-item-id-repair.ts +54 -6
  660. package/src/server/responses-json-events.ts +90 -0
  661. package/src/server/responses-model-rewrite.ts +29 -0
  662. package/src/server/responses-reasoning-summary-rewrite.ts +178 -0
  663. package/src/server/responses-snapshot-repair.ts +621 -0
  664. package/src/server/responses-terminal-repair.ts +363 -0
  665. package/src/server/responses-tool-search-repair.ts +267 -0
  666. package/src/server/responses-undeclared-tool-guard.ts +376 -0
  667. package/src/server/responses.ts +18 -2
  668. package/src/server/search.ts +78 -13
  669. package/src/server/sse-frame-buffer.ts +319 -0
  670. package/src/server/sse-payload-rewrite.ts +110 -22
  671. package/src/server/startup-action-control.ts +8 -1
  672. package/src/server/startup-health-cache.ts +19 -1
  673. package/src/server/system-env.ts +80 -9
  674. package/src/server/ws-bridge.ts +53 -40
  675. package/src/service-manager-probe.ts +892 -0
  676. package/src/service.ts +1153 -94
  677. package/src/sidecar/auth.ts +92 -0
  678. package/src/sidecar/candidates.ts +83 -0
  679. package/src/storage/cleanup.ts +2 -2
  680. package/src/storage/policy-job.ts +14 -4
  681. package/src/storage/policy.ts +88 -23
  682. package/src/storage/scanner.ts +1 -1
  683. package/src/storage/worker-lifecycle.ts +14 -14
  684. package/src/tray/windows-tray.ps1 +83 -9
  685. package/src/tray/windows.ts +43 -16
  686. package/src/types/accounts.ts +37 -0
  687. package/src/types/config.ts +911 -0
  688. package/src/types/provider.ts +614 -0
  689. package/src/types/request.ts +397 -0
  690. package/src/types/tools.ts +236 -0
  691. package/src/types/wire.ts +80 -0
  692. package/src/types.ts +107 -1237
  693. package/src/update/index.ts +37 -23
  694. package/src/update/job.ts +445 -68
  695. package/src/update/notify.ts +12 -6
  696. package/src/update/npm-cache-preflight.d.mts +47 -0
  697. package/src/update/npm-cache-preflight.mjs +201 -0
  698. package/src/update/transactional-install.d.mts +22 -0
  699. package/src/update/transactional-install.mjs +266 -0
  700. package/src/usage/cost.ts +0 -0
  701. package/src/usage/expected-prices.ts +287 -16
  702. package/src/usage/log.ts +666 -50
  703. package/src/usage/summary.ts +388 -17
  704. package/src/usage/user-cost-overlay-reconciler.ts +313 -0
  705. package/src/usage/user-cost-overlays.ts +314 -0
  706. package/src/vision/anthropic-describe.ts +10 -6
  707. package/src/vision/backends.ts +97 -0
  708. package/src/vision/describe.ts +27 -16
  709. package/src/vision/eligibility.ts +250 -0
  710. package/src/vision/index.ts +238 -24
  711. package/src/vision/reasoning.ts +55 -0
  712. package/src/vision/routed-describe.ts +175 -0
  713. package/src/vision/timeout-bounds.ts +9 -0
  714. package/src/web-search/anthropic-executor.ts +13 -7
  715. package/src/web-search/backends.ts +108 -0
  716. package/src/web-search/exa-executor.ts +119 -0
  717. package/src/web-search/executor.ts +21 -6
  718. package/src/web-search/gemini-executor.ts +141 -0
  719. package/src/web-search/index.ts +150 -15
  720. package/src/web-search/loop.ts +284 -54
  721. package/src/web-search/parse.ts +125 -30
  722. package/src/web-search/sources.ts +60 -0
  723. package/src/web-search/xai-executor.ts +219 -0
  724. package/gui/dist/assets/index-DTpMHS4F.js +0 -67
  725. package/gui/dist/assets/index-ZNVDE3C7.css +0 -1
  726. package/gui/dist/provider-icons/antigravity.svg +0 -1
  727. package/gui/dist/provider-icons/claude.svg +0 -1
  728. package/gui/dist/provider-icons/copilot.svg +0 -1
  729. package/gui/dist/provider-icons/cursor.svg +0 -2
  730. package/gui/dist/provider-icons/gemini.svg +0 -1
  731. package/gui/dist/provider-icons/grok-color.svg +0 -1
  732. package/gui/dist/provider-icons/kiro.svg +0 -14
  733. package/src/cli/internal-dispatch.ts +0 -20
@@ -1,6 +1,7 @@
1
- import { chmodSync, existsSync, lstatSync, mkdirSync, opendirSync, readFileSync, rmSync, unlinkSync } from "node:fs";
1
+ import { chmodSync, existsSync, lstatSync, mkdirSync, opendirSync, readFileSync, rmSync, statSync, unlinkSync } from "node:fs";
2
+ import { uptime } from "node:os";
2
3
  import { dirname, join } from "node:path";
3
- import { atomicWriteFileAsync, getConfigDir } from "../config";
4
+ import { atomicWriteFileAsync, getConfigDir, resolveWriteTarget } from "../config";
4
5
  import { enforceAppOwnedMemoryBudget, type RetainedStoreSnapshot } from "../lib/app-owned-memory";
5
6
  import type { OcxProviderContinuationState } from "../types";
6
7
  import {
@@ -9,6 +10,7 @@ import {
9
10
  readResponseSpill,
10
11
  recoverOrphanedResponseSpills,
11
12
  responseSpillDirectory,
13
+ responseSpillPayloadCap,
12
14
  type ResponseSpillRef,
13
15
  writeResponseSpillDurably,
14
16
  } from "./spill-store";
@@ -16,6 +18,12 @@ import {
16
18
  const MAX_STORED_RESPONSES = 1_000;
17
19
  const RESPONSE_TTL_MS = 60 * 60 * 1_000;
18
20
  const SNAPSHOT_DEBOUNCE_MS = 2_000;
21
+ /** Snapshot size below which the debounce stays at its base value. */
22
+ const SNAPSHOT_DEBOUNCE_SCALE_FROM_BYTES = 1 * 1024 * 1024;
23
+ /** Ceiling for the stretched debounce. Continuation state is only read after a
24
+ * restart, and a graceful shutdown flushes, so the exposure a longer debounce adds
25
+ * is bounded by a hard kill — paid against rewriting the whole snapshot every 2 s. */
26
+ const SNAPSHOT_DEBOUNCE_MAX_MS = 30_000;
19
27
  /** In-memory high-water byte cap across all entries. Forced store:false retention (kiro/cursor
20
28
  * continuation chains) stores the full expanded input each turn — ~quadratic bytes per chain —
21
29
  * so a count cap alone cannot bound memory. Oldest-first eviction applies past this mark. */
@@ -23,16 +31,34 @@ export const MAX_STORED_RESPONSE_BYTES = 64 * 1024 * 1024;
23
31
  /** Legacy snapshot selection only. Spill demotion is governed solely by the RAM cap above. */
24
32
  const SNAPSHOT_ENTRY_MAX_BYTES = 2 * 1024 * 1024;
25
33
  const SNAPSHOT_TOTAL_MAX_BYTES = 24 * 1024 * 1024;
34
+ /** Refuse-to-parse ceiling for an existing snapshot file (above the 24 MiB write
35
+ * bound, so anything we wrote ourselves always loads; guards against externally
36
+ * planted or pre-cap unbounded files being parsed whole). */
37
+ const SNAPSHOT_FILE_MAX_BYTES = 32 * 1024 * 1024;
26
38
  const STALE_TEMP_GRACE_MS = 15 * 60 * 1_000;
27
39
  const STALE_TEMP_MAX_ENTRIES = 4_096;
28
40
  const STALE_TEMP_MAX_CLEANUPS = 512;
41
+ /** Absorbs `os.uptime()` granularity only. It is deliberately NOT the safety margin:
42
+ * the unconditional 15-minute grace above is (see the boot floor in the scan loop). */
43
+ const BOOT_FLOOR_SKEW_MS = 60 * 1_000;
44
+ /** Per-tick budget for the periodic reclaim. Smaller than the startup budget because the
45
+ * periodic pass runs synchronously on the serving process's event loop every 60 s. */
46
+ const PERIODIC_TEMP_MAX_ENTRIES = 512;
47
+ const PERIODIC_TEMP_MAX_CLEANUPS = 64;
48
+ /** Wall-clock ceiling for one periodic scan. An entry cap bounds syscalls, not time: on a
49
+ * network-mounted config dir each `lstat` can cost 10-20 ms, which would stall in-flight
50
+ * streams. Reclaim is idempotent, so a truncated tick simply resumes on the next one. */
51
+ const PERIODIC_TEMP_SCAN_DEADLINE_MS = 25;
29
52
  const RESPONSE_STATE_TEMP_NAME = /^responses-state\.json\.ocx\.(\d+)\.(\d+)\.tmp$/;
30
53
  const MAX_SNAPSHOT_REWRITE_ATTEMPTS = 4;
31
54
 
32
55
  interface ResidentResponseState {
33
56
  kind: "resident";
34
57
  createdAt: number;
58
+ clientThreadId?: string;
35
59
  items: unknown[];
60
+ /** Index in `items` where provider output begins; see clientCarriedPrefixLength. */
61
+ providerOutputStart?: number;
36
62
  providers?: OcxProviderContinuationState;
37
63
  sizeBytes: number;
38
64
  }
@@ -40,6 +66,9 @@ interface ResidentResponseState {
40
66
  interface SpilledResponseState {
41
67
  kind: "spill";
42
68
  createdAt: number;
69
+ clientThreadId?: string;
70
+ /** Mirrors the spilled payload boundary so a spilled entry keeps its anchor. */
71
+ providerOutputStart?: number;
43
72
  providers?: OcxProviderContinuationState;
44
73
  spill: ResponseSpillRef;
45
74
  sizeBytes: number;
@@ -56,17 +85,69 @@ type ResidentInput = Omit<ResidentResponseState, "kind" | "sizeBytes">;
56
85
 
57
86
  export type PreviousResponseReplayFailure = {
58
87
  code: "previous_response_not_found";
59
- reason: "spill_missing" | "spill_corrupt" | "spill_failed";
88
+ reason: "spill_missing" | "spill_corrupt" | "spill_failed" | "spill_too_large";
60
89
  };
61
90
 
62
91
  const states = new Map<string, StoredResponseState>();
92
+ const replayScopeMismatches = new WeakSet<object>();
63
93
  let storedResponseBytes = 0;
64
94
  let residentResponseBytes = 0;
65
95
  let oldestResidentId: string | undefined;
66
96
  let oldestResidentAt: number | null = null;
67
97
  let byteCapOverride: number | null = null;
68
98
  let stateRevision = 0;
99
+ /** Byte length and digest of the last snapshot actually written, for the
100
+ * identical-payload skip and the size-scaled debounce. The payload itself is not
101
+ * retained: at the 24 MiB bound that would double the snapshot's memory cost. */
102
+ let lastSnapshotBytes = 0;
103
+ let lastSnapshotDigest: string | null = null;
104
+ // The resolved file the digest above describes. Keeping it means a config-dir
105
+ // change or a retargeted symlink is a miss rather than a false "unchanged".
106
+ let lastSnapshotTarget: string | null = null;
107
+
108
+ /**
109
+ * Is the snapshot on disk still byte-for-byte what we last wrote?
110
+ *
111
+ * The cached digest proves what this process wrote, not what is there now. Size is
112
+ * checked first so the common mismatch costs a `stat`, and the content comparison
113
+ * only runs when the size already agrees. Any read failure answers "no" and the
114
+ * caller rewrites — the safe direction.
115
+ */
116
+ async function snapshotOnDiskMatches(path: string, payload: string, payloadBytes: number): Promise<boolean> {
117
+ try {
118
+ const file = Bun.file(path);
119
+ if (file.size !== payloadBytes) return false;
120
+ if (await file.text() !== payload) return false;
121
+ // Content matching is not the whole invariant. This file holds persisted request
122
+ // and response bodies, and `atomicWriteFileAsync` writes it owner-only; the
123
+ // unconditional rewrite used to restore that on every mutation. Skipping without
124
+ // checking would let a broadened mode persist indefinitely, so treat a widened
125
+ // file as "does not match" and let the caller rewrite it through the hardening
126
+ // path. POSIX only — Windows ACLs are re-applied by that same write path.
127
+ if (process.platform !== "win32") {
128
+ const mode = statSync(path).mode & 0o777;
129
+ if (mode !== 0o600) return false;
130
+ }
131
+ return true;
132
+ } catch {
133
+ return false;
134
+ }
135
+ }
69
136
  const spillCounters = { writes: 0, writeFailures: 0, readFailures: 0 };
137
+ /**
138
+ * Admission-boundary observability (test-visible). directSpills: oversized
139
+ * candidates routed straight to durable spill without a resident stay or
140
+ * unrelated demotion. oversizedDrops: candidates above the single-spill
141
+ * payload ceiling, tombstoned instead of retained. snapshotOversizedRefusals:
142
+ * snapshot files refused before parse.
143
+ */
144
+ const admissionCounters = { directSpills: 0, oversizedDrops: 0, snapshotOversizedRefusals: 0 };
145
+ let replayScopeMismatchDrops = 0;
146
+
147
+ /** Test-only: admission-boundary counters (proves the new paths fire). */
148
+ export function responseAdmissionCountersForTests(): Readonly<typeof admissionCounters> {
149
+ return admissionCounters;
150
+ }
70
151
  // Superseded spill generations awaiting a durable snapshot before unlink
71
152
  // (review C1-1: unlinking at swap time races a crash against the debounced
72
153
  // snapshot — the reloaded OLD stub would point at a deleted file).
@@ -106,7 +187,9 @@ function measureResidentEntry(id: string, entry: ResidentInput): ResidentRespons
106
187
  const sizeBytes = serializedBytes({
107
188
  responseId: id,
108
189
  createdAt: entry.createdAt,
190
+ ...(entry.clientThreadId ? { clientThreadId: entry.clientThreadId } : {}),
109
191
  items: entry.items,
192
+ ...(entry.providerOutputStart !== undefined ? { providerOutputStart: entry.providerOutputStart } : {}),
110
193
  ...(entry.providers ? { providers: entry.providers } : {}),
111
194
  });
112
195
  return sizeBytes === null ? null : { kind: "resident", ...entry, sizeBytes };
@@ -177,12 +260,28 @@ function deleteEntry(id: string, options: { deleteSpill?: boolean } = {}): void
177
260
  if (options.deleteSpill !== false) deleteOwnedSpills(existing);
178
261
  }
179
262
 
180
- function replaceWithSpillFailure(id: string, expected?: StoredResponseState): void {
263
+ function replaceWithSpillFailure(
264
+ id: string,
265
+ expected?: StoredResponseState,
266
+ options: { deferSpillUnlink?: boolean } = {},
267
+ ): void {
181
268
  const existing = states.get(id);
182
269
  if (expected && existing !== expected) return;
183
270
  const failed = tombstone(id, expected?.createdAt ?? existing?.createdAt ?? now());
184
271
  if (replaceMapEntry(id, failed, expected)) {
185
- if (existing) deleteOwnedSpills(existing);
272
+ if (existing) {
273
+ if (options.deferSpillUnlink && existing.kind === "spill") {
274
+ // Crash consistency (same rule as replaceSpillEntryAtomically): the old
275
+ // durable snapshot still references this generation until the tombstone
276
+ // itself is durable — queue the unlink for the next stable persist.
277
+ pendingSpillUnlinks.push(existing.spill);
278
+ while (pendingSpillUnlinks.length > PENDING_SPILL_UNLINKS_MAX) {
279
+ deleteResponseSpill(pendingSpillUnlinks.shift()!);
280
+ }
281
+ } else {
282
+ deleteOwnedSpills(existing);
283
+ }
284
+ }
186
285
  }
187
286
  }
188
287
 
@@ -190,6 +289,7 @@ function swapResidentForSpill(id: string, expected: ResidentResponseState, ref:
190
289
  const base: Omit<SpilledResponseState, "sizeBytes"> = {
191
290
  kind: "spill",
192
291
  createdAt: expected.createdAt,
292
+ ...(expected.clientThreadId ? { clientThreadId: expected.clientThreadId } : {}),
193
293
  ...(expected.providers ? { providers: expected.providers } : {}),
194
294
  spill: ref,
195
295
  };
@@ -210,12 +310,16 @@ function replaceSpillEntryAtomically(
210
310
  try {
211
311
  const ref = writeResponseSpillDurably(id, {
212
312
  createdAt: candidate.createdAt,
313
+ ...(candidate.clientThreadId ? { clientThreadId: candidate.clientThreadId } : {}),
213
314
  items: candidate.items,
315
+ ...(candidate.providerOutputStart !== undefined ? { providerOutputStart: candidate.providerOutputStart } : {}),
214
316
  ...(candidate.providers ? { providers: candidate.providers } : {}),
215
317
  });
216
318
  const base: Omit<SpilledResponseState, "sizeBytes"> = {
217
319
  kind: "spill",
218
320
  createdAt: candidate.createdAt,
321
+ ...(candidate.clientThreadId ? { clientThreadId: candidate.clientThreadId } : {}),
322
+ ...(candidate.providerOutputStart !== undefined ? { providerOutputStart: candidate.providerOutputStart } : {}),
219
323
  ...(candidate.providers ? { providers: candidate.providers } : {}),
220
324
  spill: ref,
221
325
  };
@@ -236,7 +340,9 @@ function replaceSpillEntryAtomically(
236
340
  }
237
341
  } catch {
238
342
  spillCounters.writeFailures += 1;
239
- replaceWithSpillFailure(id, expected);
343
+ // deferSpillUnlink: the durable snapshot may still reference the old
344
+ // generation; deleting it now would strand the old stub after a crash.
345
+ replaceWithSpillFailure(id, expected, { deferSpillUnlink: true });
240
346
  }
241
347
  }
242
348
 
@@ -251,6 +357,11 @@ function setResidentEntry(id: string, entry: ResidentInput): void {
251
357
  pruneResponses();
252
358
  return;
253
359
  }
360
+ if (candidate.sizeBytes > byteCap()) {
361
+ admitOversizedCandidate(id, candidate, expected);
362
+ pruneResponses();
363
+ return;
364
+ }
254
365
  if (expected?.kind === "spill") {
255
366
  replaceSpillEntryAtomically(id, expected, candidate);
256
367
  pruneResponses();
@@ -260,9 +371,76 @@ function setResidentEntry(id: string, entry: ResidentInput): void {
260
371
  pruneResponses();
261
372
  }
262
373
 
263
- // Expansion provenance must stay proxy-private: a WeakMap distinguishes replayed history from the
374
+ /**
375
+ * Admission boundary for candidates that can never fit as resident (larger
376
+ * than the whole resident-map cap). Writes them DIRECTLY to durable spill and
377
+ * installs only the stub — the oversized candidate never becomes resident and
378
+ * no unrelated resident is demoted to make room for it. Candidates above the
379
+ * single-spill payload ceiling are tombstoned instead: retaining a spill the
380
+ * replay ceiling would refuse to read is write-only waste.
381
+ */
382
+ function admitOversizedCandidate(
383
+ id: string,
384
+ candidate: ResidentResponseState,
385
+ expected?: StoredResponseState,
386
+ ): void {
387
+ if (candidate.sizeBytes > responseSpillPayloadCap()) {
388
+ admissionCounters.oversizedDrops += 1;
389
+ replaceWithSpillFailure(id, expected, { deferSpillUnlink: true });
390
+ return;
391
+ }
392
+ try {
393
+ const ref = writeResponseSpillDurably(id, {
394
+ createdAt: candidate.createdAt,
395
+ ...(candidate.clientThreadId ? { clientThreadId: candidate.clientThreadId } : {}),
396
+ items: candidate.items,
397
+ ...(candidate.providerOutputStart !== undefined ? { providerOutputStart: candidate.providerOutputStart } : {}),
398
+ ...(candidate.providers ? { providers: candidate.providers } : {}),
399
+ });
400
+ // Enforce the ceiling against the REAL envelope: the spill payload adds
401
+ // the {version, responseId, ...} wrapper, so a candidate within the
402
+ // wrapper's size of the cap would otherwise be retained unreadably.
403
+ if (ref.payloadBytes > responseSpillPayloadCap()) {
404
+ deleteResponseSpill(ref);
405
+ admissionCounters.oversizedDrops += 1;
406
+ replaceWithSpillFailure(id, expected, { deferSpillUnlink: true });
407
+ return;
408
+ }
409
+ const base: Omit<SpilledResponseState, "sizeBytes"> = {
410
+ kind: "spill",
411
+ createdAt: candidate.createdAt,
412
+ ...(candidate.clientThreadId ? { clientThreadId: candidate.clientThreadId } : {}),
413
+ ...(candidate.providers ? { providers: candidate.providers } : {}),
414
+ spill: ref,
415
+ };
416
+ const next: SpilledResponseState = { ...base, sizeBytes: stubSize(id, base) };
417
+ if (!replaceMapEntry(id, next, expected)) {
418
+ deleteResponseSpill(ref);
419
+ return;
420
+ }
421
+ spillCounters.writes += 1;
422
+ admissionCounters.directSpills += 1;
423
+ noteStubSwapForTest();
424
+ if (expected?.kind === "spill") {
425
+ // Same deferred-unlink rule as replaceSpillEntryAtomically: the new stub
426
+ // is durable only after the debounced snapshot, so the old generation
427
+ // stays until a stable persist drains the queue.
428
+ pendingSpillUnlinks.push(expected.spill);
429
+ while (pendingSpillUnlinks.length > PENDING_SPILL_UNLINKS_MAX) {
430
+ deleteResponseSpill(pendingSpillUnlinks.shift()!);
431
+ }
432
+ }
433
+ } catch {
434
+ spillCounters.writeFailures += 1;
435
+ replaceWithSpillFailure(id, expected, { deferSpillUnlink: true });
436
+ }
437
+ }
438
+
439
+ // Replay provenance must stay proxy-private: a WeakMap distinguishes replayed history from the
264
440
  // newly appended input suffix without adding an unknown field that native passthrough could send
265
- // upstream. The parser uses this boundary to acknowledge historical compaction markers exactly once.
441
+ // upstream. The parser uses this boundary to acknowledge historical compaction markers exactly
442
+ // once. It records the boundary whether the proxy prepended the history or the client already
443
+ // carried it — the boundary is the same either way, and only its provenance differs.
266
444
  const replayedInputPrefixLengths = new WeakMap<object, number>();
267
445
  const replayFailures = new WeakMap<object, PreviousResponseReplayFailure>();
268
446
  let loaded = false;
@@ -282,6 +460,7 @@ function snapshotPath(): string {
282
460
 
283
461
  interface LegacySnapshotState {
284
462
  createdAt?: unknown;
463
+ clientThreadId?: unknown;
285
464
  items?: unknown;
286
465
  providers?: OcxProviderContinuationState;
287
466
  conversationId?: unknown;
@@ -302,11 +481,26 @@ function loadSnapshotEntry(id: string, value: unknown): void {
302
481
  if (!value || typeof value !== "object" || Array.isArray(value)) return;
303
482
  const rec = value as LegacySnapshotState & { kind?: unknown; spill?: unknown };
304
483
  if (typeof rec.createdAt !== "number" || !Number.isFinite(rec.createdAt)) return;
484
+ const clientThreadId = typeof rec.clientThreadId === "string" && rec.clientThreadId.trim().length > 0
485
+ ? rec.clientThreadId.trim()
486
+ : undefined;
487
+ // A malformed boundary degrades to "never skip" rather than to a bad index: an untrusted
488
+ // snapshot must not be able to authorize dropping conversation history.
489
+ const anchorFor = (itemCount: number): number | undefined => {
490
+ const raw = (rec as { providerOutputStart?: unknown }).providerOutputStart;
491
+ return Number.isSafeInteger(raw) && (raw as number) >= 0 && (raw as number) <= itemCount
492
+ ? raw as number
493
+ : undefined;
494
+ };
305
495
  if (rec.kind === "spill") {
306
496
  if (!isSpillRef(rec.spill)) return;
307
497
  const base: Omit<SpilledResponseState, "sizeBytes"> = {
308
498
  kind: "spill",
309
499
  createdAt: rec.createdAt,
500
+ ...(clientThreadId ? { clientThreadId } : {}),
501
+ // Item count is unknown until materialization, so accept any non-negative integer
502
+ // here; the spill payload validator re-checks it against the real array.
503
+ ...(anchorFor(Number.MAX_SAFE_INTEGER) !== undefined ? { providerOutputStart: anchorFor(Number.MAX_SAFE_INTEGER) } : {}),
310
504
  ...(rec.providers ? { providers: rec.providers } : {}),
311
505
  spill: rec.spill,
312
506
  };
@@ -331,11 +525,23 @@ function loadSnapshotEntry(id: string, value: unknown): void {
331
525
  : undefined);
332
526
  const resident = measureResidentEntry(id, {
333
527
  createdAt: rec.createdAt,
528
+ ...(clientThreadId ? { clientThreadId } : {}),
334
529
  items: rec.items,
530
+ ...(anchorFor(rec.items.length) !== undefined ? { providerOutputStart: anchorFor(rec.items.length) } : {}),
335
531
  ...(providers ? { providers } : {}),
336
532
  });
337
- if (resident) replaceMapEntry(id, resident);
338
- else replaceMapEntry(id, tombstone(id, rec.createdAt));
533
+ if (!resident) {
534
+ replaceMapEntry(id, tombstone(id, rec.createdAt));
535
+ return;
536
+ }
537
+ // Same admission boundary as live writes: an oversized snapshot row goes
538
+ // straight to spill (or tombstone above the payload ceiling) instead of
539
+ // entering the resident map and demoting unrelated rows on the first prune.
540
+ if (resident.sizeBytes > byteCap()) {
541
+ admitOversizedCandidate(id, resident, undefined);
542
+ return;
543
+ }
544
+ replaceMapEntry(id, resident);
339
545
  }
340
546
 
341
547
  export interface ResponseStateTempRecoveryResult {
@@ -343,19 +549,37 @@ export interface ResponseStateTempRecoveryResult {
343
549
  removed: number;
344
550
  failed: number;
345
551
  bytesRemoved: number;
552
+ /** Entries that passed EVERY gate and would be reclaimed. In a dry run nothing is
553
+ * unlinked, so this is the only honest count to show an operator: `matched` is
554
+ * incremented before the file-type, age, boot-floor, and liveness gates. */
555
+ eligible: number;
556
+ /** Total size of the `eligible` entries. */
557
+ eligibleBytes: number;
558
+ /** The scan stopped on a budget (entry cap, cleanup cap, or deadline) rather than reaching
559
+ * the end of the directory, so the counts below describe a prefix of the backlog and not
560
+ * the backlog. `eligible > removed + failed` cannot express this: outside a dry run every
561
+ * eligible entry is unlinked or failed on the same iteration, so the two are always equal
562
+ * and a comparison between them is dead code. */
563
+ truncated: boolean;
346
564
  }
347
565
 
348
566
  interface ResponseStateTempRecoveryIO {
349
567
  now: () => number;
568
+ /** Approximate epoch ms of the current boot; see the boot floor in the scan loop. */
569
+ bootTime: () => number;
350
570
  list: (dir: string) => Iterable<string>;
351
571
  inspect: (path: string) => { isFile: boolean; mtimeMs: number; size: number };
352
572
  isProcessAlive: (pid: number) => boolean;
353
573
  unlink: (path: string) => void;
354
574
  }
355
575
 
356
- type ResponseStateTempRecoveryOptions = Partial<ResponseStateTempRecoveryIO> & {
576
+ export type ResponseStateTempRecoveryOptions = Partial<ResponseStateTempRecoveryIO> & {
357
577
  maxEntries?: number;
358
578
  maxCleanups?: number;
579
+ /** Wall-clock ceiling for the scan, or null/undefined for no deadline (startup path). */
580
+ deadlineMs?: number | null;
581
+ /** Report only: apply every gate, count what would be reclaimed, unlink nothing. */
582
+ dryRun?: boolean;
359
583
  };
360
584
 
361
585
  function processIsAlive(pid: number): boolean {
@@ -372,6 +596,7 @@ function processIsAlive(pid: number): boolean {
372
596
 
373
597
  const responseStateTempRecoveryIO: ResponseStateTempRecoveryIO = {
374
598
  now: Date.now,
599
+ bootTime: () => Date.now() - uptime() * 1_000,
375
600
  list: function* list(dir) {
376
601
  const handle = opendirSync(dir);
377
602
  try {
@@ -398,26 +623,56 @@ export function recoverStaleResponseStateTemps(
398
623
  dir = getConfigDir(),
399
624
  options: ResponseStateTempRecoveryOptions = {},
400
625
  ): ResponseStateTempRecoveryResult {
401
- const { maxEntries = STALE_TEMP_MAX_ENTRIES, maxCleanups = STALE_TEMP_MAX_CLEANUPS, ...overrides } = options;
626
+ const {
627
+ maxEntries = STALE_TEMP_MAX_ENTRIES,
628
+ maxCleanups = STALE_TEMP_MAX_CLEANUPS,
629
+ deadlineMs = null,
630
+ dryRun = false,
631
+ ...overrides
632
+ } = options;
402
633
  const io = { ...responseStateTempRecoveryIO, ...overrides };
403
634
  const result: ResponseStateTempRecoveryResult = {
404
635
  matched: 0,
405
636
  removed: 0,
406
637
  failed: 0,
407
638
  bytesRemoved: 0,
639
+ eligible: 0,
640
+ eligibleBytes: 0,
641
+ truncated: false,
408
642
  };
643
+ const startedAt = io.now();
644
+ // One probe per scan, not one per entry. A non-finite or future-dated boot is anomalous, and
645
+ // clamping it to "now" would be the WORST response: the floor would then retire the liveness
646
+ // probe for every file older than the skew, which is every file past the grace. Disable it
647
+ // instead -- an absent floor only costs a missed reclaim, never a wrong one.
648
+ const rawBoot = io.bootTime();
649
+ const bootMs = Number.isFinite(rawBoot) && rawBoot <= startedAt ? rawBoot : Number.NEGATIVE_INFINITY;
409
650
  let names: Iterable<string>;
410
651
  try { names = io.list(dir); } catch { return result; }
411
652
  let iterator: Iterator<string>;
412
653
  try { iterator = names[Symbol.iterator](); } catch { return result; }
413
654
  let scanned = 0;
655
+ // Every early exit runs through this. The production `list` is a generator that closes its
656
+ // directory handle in a `finally`, and a `finally` does NOT run when the consumer simply
657
+ // stops calling `next()` -- only `return()` resumes the generator to completion. Breaking
658
+ // out of the loop directly therefore leaked one directory handle per truncated scan, and the
659
+ // periodic reclaim truncates on purpose (entry cap, cleanup cap, deadline), so on a slow
660
+ // filesystem that is a leak per tick, forever.
661
+ const stopScan = (): ResponseStateTempRecoveryResult => {
662
+ try { iterator.return?.(); } catch { /* closing is best-effort; never fail a reclaim on it */ }
663
+ return result;
664
+ };
414
665
  for (;;) {
415
666
  let next: IteratorResult<string>;
416
667
  try { next = iterator.next(); } catch { return result; }
417
668
  if (next.done) break;
418
669
  const name = next.value;
419
670
  scanned += 1;
420
- if (scanned > maxEntries || result.removed + result.failed >= maxCleanups) break;
671
+ // A dry run performs no cleanups, so bounding it by the cleanup budget would truncate
672
+ // the very report an operator uses to size the problem.
673
+ if (scanned > maxEntries) { result.truncated = true; return stopScan(); }
674
+ if (!dryRun && result.removed + result.failed >= maxCleanups) { result.truncated = true; return stopScan(); }
675
+ if (deadlineMs !== null && io.now() - startedAt > deadlineMs) { result.truncated = true; return stopScan(); }
421
676
  const match = RESPONSE_STATE_TEMP_NAME.exec(name);
422
677
  if (!match) continue;
423
678
  result.matched += 1;
@@ -428,13 +683,34 @@ export function recoverStaleResponseStateTemps(
428
683
  let file: ReturnType<ResponseStateTempRecoveryIO["inspect"]>;
429
684
  try { file = io.inspect(path); } catch { continue; }
430
685
  if (!file.isFile || io.now() - file.mtimeMs < STALE_TEMP_GRACE_MS) continue;
431
- if (pid === process.pid || io.isProcessAlive(pid)) continue;
686
+ // Boot floor. After a reboot the original writer's pid is routinely reused, which makes
687
+ // the liveness skip PERMANENT: the 15-minute grace above is a lower bound and never
688
+ // expires it, so the file is skipped on every future pass forever. A temp older than
689
+ // this boot cannot be owned by the pid we would probe, so the probe is vacuous and we
690
+ // retire it. This does NOT claim the file is provably dead: under a shared-volume
691
+ // container, suspend-excluding uptime, or a network config dir the computed boot can
692
+ // land after the real one. The unconditional 15-minute grace above remains the safety
693
+ // floor, and this process's own temps are never touched.
694
+ const predatesBoot = file.mtimeMs < bootMs - BOOT_FLOOR_SKEW_MS;
695
+ if (pid === process.pid) continue;
696
+ if (!predatesBoot && io.isProcessAlive(pid)) continue;
697
+
698
+ result.eligible += 1;
699
+ result.eligibleBytes += file.size;
700
+ if (dryRun) continue;
432
701
 
433
702
  try {
434
703
  io.unlink(path);
435
704
  result.removed += 1;
436
705
  result.bytesRemoved += file.size;
437
- } catch {
706
+ } catch (error) {
707
+ // Another proxy sharing this config dir may have won the race. A file that is already
708
+ // gone is reclaimed, not a failure -- reporting it as one would surface "in use or
709
+ // locked" to an operator for a file nobody holds.
710
+ if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
711
+ result.removed += 1;
712
+ continue;
713
+ }
438
714
  // Locked files remain for a later startup. Do not truncate by path: a same-user
439
715
  // replacement could turn that fallback into an arbitrary symlink-target write.
440
716
  result.failed += 1;
@@ -443,6 +719,23 @@ export function recoverStaleResponseStateTemps(
443
719
  return result;
444
720
  }
445
721
 
722
+ /**
723
+ * Literal config dir plus the snapshot's resolved dir. Atomic writes place their temp beside
724
+ * the RESOLVED target, so a symlinked snapshot (dotfiles-managed config dir) strands temps in
725
+ * the link's real directory where a scan of the literal dir would never see them. The two
726
+ * collapse to one when nothing is symlinked.
727
+ */
728
+ function responseStateSweepDirectories(): Set<string> {
729
+ const path = snapshotPath();
730
+ let resolvedDir = dirname(path);
731
+ try {
732
+ resolvedDir = dirname(resolveWriteTarget(path));
733
+ } catch {
734
+ /* unresolvable link: sweep the literal dir only */
735
+ }
736
+ return new Set([dirname(path), resolvedDir]);
737
+ }
738
+
446
739
  /**
447
740
  * Best-effort disk snapshot so previous_response_id chains survive a proxy restart (the
448
741
  * dominant expansion-miss cause: an in-memory-only store dies with the process, and the next
@@ -454,18 +747,43 @@ function ensureLoaded(): void {
454
747
  if (loaded) return;
455
748
  loaded = true;
456
749
  const path = snapshotPath();
750
+ // Atomic writes place their temp beside the RESOLVED target, so a symlinked
751
+ // snapshot (dotfiles-managed config dir) strands temps in the link's real
752
+ // directory where a scan of the literal config dir would never see them.
753
+ // Both locations are swept; they collapse to one when nothing is symlinked.
754
+ // resolveWriteTarget refuses a dangling link; snapshot loading stays independent.
755
+ let resolvedDir = dirname(path);
457
756
  try {
458
- recoverStaleResponseStateTemps(dirname(path));
757
+ resolvedDir = dirname(resolveWriteTarget(path));
459
758
  } catch {
460
- /* best-effort cleanup only; snapshot loading must remain independent */
759
+ /* unresolvable link: sweep the literal dir only */
760
+ }
761
+ for (const dir of new Set([dirname(path), resolvedDir])) {
762
+ try {
763
+ recoverStaleResponseStateTemps(dir);
764
+ } catch {
765
+ /* best-effort cleanup only; snapshot loading must remain independent */
766
+ }
461
767
  }
462
768
  try {
463
769
  if (existsSync(path)) {
464
- const raw = JSON.parse(readFileSync(path, "utf-8")) as { version?: unknown; states?: unknown };
465
- if ((raw.version === 1 || raw.version === 2) && Array.isArray(raw.states)) {
466
- for (const entry of raw.states) {
467
- if (!Array.isArray(entry) || entry.length !== 2 || typeof entry[0] !== "string") continue;
468
- loadSnapshotEntry(entry[0], entry[1]);
770
+ // Bound the read BEFORE parse: the 24 MiB write cap constrains snapshots
771
+ // this process wrote, not a pre-existing oversized file. statSync follows
772
+ // symlinks deliberately readFileSync below follows them too, so the
773
+ // size gate must measure the same target the read would.
774
+ const stat = statSync(path);
775
+ if (!stat.isFile()) {
776
+ // Symlink to a FIFO/device (e.g. /dev/zero): reading would block or
777
+ // return unbounded input. Only regular files are ever parsed.
778
+ } else if (stat.size > SNAPSHOT_FILE_MAX_BYTES) {
779
+ admissionCounters.snapshotOversizedRefusals += 1;
780
+ } else {
781
+ const raw = JSON.parse(readFileSync(path, "utf-8")) as { version?: unknown; states?: unknown };
782
+ if ((raw.version === 1 || raw.version === 2) && Array.isArray(raw.states)) {
783
+ for (const entry of raw.states) {
784
+ if (!Array.isArray(entry) || entry.length !== 2 || typeof entry[0] !== "string") continue;
785
+ loadSnapshotEntry(entry[0], entry[1]);
786
+ }
469
787
  }
470
788
  }
471
789
  }
@@ -504,16 +822,47 @@ async function writeBoundedSnapshot(path: string): Promise<SnapshotWriteOutcome>
504
822
  persistable = smallState;
505
823
  }
506
824
  const persistEntry: [string, unknown] = [id, persistable];
507
- const size = JSON.stringify(persistEntry).length;
825
+ // UTF-8 bytes, not UTF-16 code units: multibyte items otherwise slip
826
+ // past both snapshot caps at up to 2x the intended size.
827
+ const size = Buffer.byteLength(JSON.stringify(persistEntry), "utf8");
508
828
  if (state.kind === "resident" && size > SNAPSHOT_ENTRY_MAX_BYTES) continue;
509
829
  if (total + size > SNAPSHOT_TOTAL_MAX_BYTES) break;
510
830
  total += size;
511
831
  entries.push(persistEntry);
512
832
  }
513
833
  entries.reverse();
514
- mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
515
- try { chmodSync(dirname(path), 0o700); } catch { /* best-effort (e.g. Windows) */ }
516
- await atomicWriteFileAsync(path, JSON.stringify({ version: 2, states: entries }));
834
+ const payload = JSON.stringify({ version: 2, states: entries });
835
+ const payloadBytes = Buffer.byteLength(payload, "utf8");
836
+ const payloadDigest = Bun.hash(payload).toString(36);
837
+ // A mutation does not always change what gets persisted: entries past the
838
+ // per-entry or total byte bound are dropped from the selection, and spill
839
+ // demotion moves bytes out of it. Re-writing a byte-identical 24 MiB file
840
+ // buys nothing, so compare first — but the cached digest describes what THIS
841
+ // process last wrote, which is not the same claim as "that is what is on disk
842
+ // now". A second proxy sharing the home, or anything that rewrites the file
843
+ // in place, leaves the digest describing bytes that are gone. Before every
844
+ // release-of-a-write, the previous behaviour rewrote unconditionally and so
845
+ // repaired that silently; skipping without checking would turn a repaired
846
+ // snapshot into a lost one at the next restart.
847
+ //
848
+ // Verify against the file itself, keyed to the resolved target so a retargeted
849
+ // symlink is also a miss. Reading back a matching-size file costs far less
850
+ // than the atomic replace it avoids, and only happens when the digest already
851
+ // matched — the amplification this fixes is the repeated WRITE, not the read.
852
+ const unchanged = lastSnapshotDigest !== null
853
+ && payloadDigest === lastSnapshotDigest
854
+ && payloadBytes === lastSnapshotBytes
855
+ && lastSnapshotTarget === resolveWriteTarget(path)
856
+ && existsSync(path)
857
+ && await snapshotOnDiskMatches(path, payload, payloadBytes);
858
+ if (!unchanged) {
859
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
860
+ try { chmodSync(dirname(path), 0o700); } catch { /* best-effort (e.g. Windows) */ }
861
+ await atomicWriteFileAsync(path, payload);
862
+ lastSnapshotDigest = payloadDigest;
863
+ lastSnapshotBytes = payloadBytes;
864
+ lastSnapshotTarget = resolveWriteTarget(path);
865
+ }
517
866
  persistAttemptHookForTests?.();
518
867
  if (revision === stateRevision) return "stable";
519
868
  }
@@ -532,11 +881,26 @@ function drainPendingSpillUnlinks(): void {
532
881
  }
533
882
  }
534
883
 
884
+ /**
885
+ * Debounce scaled by the size of the last snapshot written.
886
+ *
887
+ * The whole snapshot is re-serialized and atomically replaced on every flush, so at
888
+ * the 24 MiB bound a fixed 2 s debounce is up to ~12 MB/s of write amplification for
889
+ * state nothing reads until the next start (#2460). Small snapshots keep the base
890
+ * cadence; the stretch is linear in size and clamped, so the write rate is roughly
891
+ * flat instead of growing with the file.
892
+ */
893
+ function snapshotDebounceMs(): number {
894
+ if (lastSnapshotBytes <= SNAPSHOT_DEBOUNCE_SCALE_FROM_BYTES) return SNAPSHOT_DEBOUNCE_MS;
895
+ const scaled = Math.round(SNAPSHOT_DEBOUNCE_MS * (lastSnapshotBytes / SNAPSHOT_DEBOUNCE_SCALE_FROM_BYTES));
896
+ return Math.min(scaled, SNAPSHOT_DEBOUNCE_MAX_MS);
897
+ }
898
+
535
899
  function schedulePersistAt(path: string, replace = false): void {
536
900
  if (persistTimer && !replace) return;
537
901
  if (persistTimer) clearTimeout(persistTimer);
538
902
  pendingPersistPath = path;
539
- persistTimer = setTimeout(() => { void persistNow(path); }, SNAPSHOT_DEBOUNCE_MS);
903
+ persistTimer = setTimeout(() => { void persistNow(path); }, snapshotDebounceMs());
540
904
  (persistTimer as { unref?: () => void }).unref?.();
541
905
  }
542
906
 
@@ -584,6 +948,94 @@ function inputItems(input: unknown): unknown[] {
584
948
  return [input];
585
949
  }
586
950
 
951
+ /** Hard cap for canonicalizing ANY item. Past it, the item is not comparable. */
952
+ const REPLAY_FINGERPRINT_MAX_BYTES = 8 * 1024;
953
+ /** Depth ceiling so a pathologically nested item cannot blow the canonicalizer. */
954
+ const REPLAY_FINGERPRINT_MAX_DEPTH = 64;
955
+
956
+ let replayOverlapSkips = 0;
957
+
958
+ /**
959
+ * Canonical, order-stable fingerprint for one input item, or null when the item cannot be
960
+ * compared safely.
961
+ *
962
+ * Byte-counted DURING the walk rather than serialize-then-measure: a tool result can be
963
+ * megabytes and this runs on the request path, so the point of the cap is to stop early,
964
+ * not to discover afterwards that we should have. Object keys are sorted so two
965
+ * semantically identical items cannot differ by key order alone.
966
+ *
967
+ * The cap applies to EVERY item. An `id`/`call_id` is additional occurrence evidence, never
968
+ * a substitute for content equality, so an over-cap identified tool item is non-comparable
969
+ * exactly like an over-cap message.
970
+ */
971
+ function replayItemFingerprint(item: unknown): string | null {
972
+ const out: string[] = [];
973
+ let bytes = 0;
974
+ const push = (text: string): boolean => {
975
+ bytes += Buffer.byteLength(text, "utf8");
976
+ if (bytes > REPLAY_FINGERPRINT_MAX_BYTES) return false;
977
+ out.push(text);
978
+ return true;
979
+ };
980
+ const walk = (value: unknown, depth: number): boolean => {
981
+ if (depth > REPLAY_FINGERPRINT_MAX_DEPTH) return false;
982
+ if (value === null || typeof value !== "object") return push(JSON.stringify(value) ?? "null");
983
+ if (Array.isArray(value)) {
984
+ if (!push("[")) return false;
985
+ for (const element of value) {
986
+ if (!walk(element, depth + 1)) return false;
987
+ if (!push(",")) return false;
988
+ }
989
+ return push("]");
990
+ }
991
+ if (!push("{")) return false;
992
+ for (const key of Object.keys(value as Record<string, unknown>).sort()) {
993
+ if (!push(JSON.stringify(key))) return false;
994
+ if (!walk((value as Record<string, unknown>)[key], depth + 1)) return false;
995
+ if (!push(",")) return false;
996
+ }
997
+ return push("}");
998
+ };
999
+ return walk(item, 0) ? out.join("") : null;
1000
+ }
1001
+
1002
+ /** Non-empty provider-issued `id`/`call_id` on an item, else null. */
1003
+ function providerIssuedIdentity(item: unknown): string | null {
1004
+ if (!item || typeof item !== "object" || Array.isArray(item)) return null;
1005
+ const record = item as { id?: unknown; call_id?: unknown };
1006
+ for (const candidate of [record.id, record.call_id]) {
1007
+ if (typeof candidate === "string" && candidate.trim().length > 0) return candidate;
1008
+ }
1009
+ return null;
1010
+ }
1011
+
1012
+ /**
1013
+ * Number of leading stored items the client already carries verbatim, or 0.
1014
+ *
1015
+ * Requires an exact ordered run: every stored item must match the client input item at the
1016
+ * same index. Any not-comparable item aborts to 0 — skipping just that item could align two
1017
+ * different occurrences and manufacture a false positive, and a false positive here deletes
1018
+ * real conversation history.
1019
+ *
1020
+ * Known gap (FU-2): stored input can contain proxy-injected guidance the client never saw,
1021
+ * and ids repaired after recording. Those sessions do not match here and expand as before.
1022
+ */
1023
+ function clientCarriedPrefixLength(stored: readonly unknown[], clientInput: readonly unknown[]): number {
1024
+ if (stored.length === 0 || clientInput.length < stored.length) return 0;
1025
+ for (let index = 0; index < stored.length; index += 1) {
1026
+ const storedPrint = replayItemFingerprint(stored[index]);
1027
+ if (storedPrint === null) return 0;
1028
+ const clientPrint = replayItemFingerprint(clientInput[index]);
1029
+ if (clientPrint === null || storedPrint !== clientPrint) return 0;
1030
+ }
1031
+ return stored.length;
1032
+ }
1033
+
1034
+ /** Test-only: replay prepends skipped because the client already carried the history. */
1035
+ export function replayOverlapSkipsForTests(): number {
1036
+ return replayOverlapSkips;
1037
+ }
1038
+
587
1039
  function pruneResponses(at = now()): void {
588
1040
  for (const [id, state] of states) {
589
1041
  if (at - state.createdAt > RESPONSE_TTL_MS) deleteEntry(id);
@@ -607,7 +1059,9 @@ function pruneResponses(at = now()): void {
607
1059
  try {
608
1060
  const ref = writeResponseSpillDurably(oldestId, {
609
1061
  createdAt: entry.createdAt,
1062
+ ...(entry.clientThreadId ? { clientThreadId: entry.clientThreadId } : {}),
610
1063
  items: entry.items,
1064
+ ...(entry.providerOutputStart !== undefined ? { providerOutputStart: entry.providerOutputStart } : {}),
611
1065
  ...(entry.providers ? { providers: entry.providers } : {}),
612
1066
  });
613
1067
  if (swapResidentForSpill(oldestId, entry, ref)) spillCounters.writes += 1;
@@ -630,6 +1084,64 @@ export function sweepExpiredResponseStates(at = now()): number {
630
1084
  return removed;
631
1085
  }
632
1086
 
1087
+ /**
1088
+ * Periodic disk reclaim for abandoned atomic-write temps.
1089
+ *
1090
+ * `ensureLoaded` sweeps once per process, at load, BEFORE that process writes anything:
1091
+ * every `schedulePersist` site is downstream of it. So a process that abandons a temp has
1092
+ * already had its only look, the 15-minute grace hides the temp its predecessor's crash
1093
+ * just produced, and `maxCleanups` caps a single pass below a large backlog. A restart
1094
+ * loop therefore accumulates monotonically. Repeating the reclaim on a timer fixes all
1095
+ * three: the grace expires into a later tick and the per-pass cap becomes a per-tick rate.
1096
+ *
1097
+ * Registered on the sweeper's LIVENESS tick, not the TTL tick: `sweepExpiredOnWrite` puts
1098
+ * `sweepExpired` on hot write paths, and a directory scan does not belong there.
1099
+ */
1100
+ export function reclaimAbandonedResponseStateTemps(
1101
+ options: ResponseStateTempRecoveryOptions = {},
1102
+ ): ResponseStateTempRecoveryResult {
1103
+ const total: ResponseStateTempRecoveryResult = {
1104
+ matched: 0, removed: 0, failed: 0, bytesRemoved: 0, eligible: 0, eligibleBytes: 0, truncated: false,
1105
+ };
1106
+ // The try encloses responseStateSweepDirectories() deliberately: recoverStaleResponseStateTemps
1107
+ // already swallows its own enumeration failures, so a catch around only that call would be
1108
+ // unreachable. snapshotPath()/getConfigDir() are the paths that can genuinely throw.
1109
+ try {
1110
+ for (const dir of responseStateSweepDirectories()) {
1111
+ const result = recoverStaleResponseStateTemps(dir, options);
1112
+ total.matched += result.matched;
1113
+ total.removed += result.removed;
1114
+ total.failed += result.failed;
1115
+ total.bytesRemoved += result.bytesRemoved;
1116
+ total.eligible += result.eligible;
1117
+ total.eligibleBytes += result.eligibleBytes;
1118
+ // Truncation anywhere makes the whole total a prefix.
1119
+ total.truncated ||= result.truncated;
1120
+ }
1121
+ } catch {
1122
+ /* best-effort: disk reclaim must never destabilize the caller */
1123
+ }
1124
+ return total;
1125
+ }
1126
+
1127
+ /**
1128
+ * Report-only counterpart for `ocx doctor`: applies every selection gate and unlinks
1129
+ * nothing. It runs the SAME predicate as the reclaim, so the report and the subsequent
1130
+ * removal cannot disagree about which files are reclaimable.
1131
+ */
1132
+ export function inspectAbandonedResponseStateTemps(): ResponseStateTempRecoveryResult {
1133
+ return reclaimAbandonedResponseStateTemps({ dryRun: true });
1134
+ }
1135
+
1136
+ /** Sweeper adapter: narrows the reclaim to the `() => number` the liveness tick expects. */
1137
+ export function sweepAbandonedResponseStateTemps(): number {
1138
+ return reclaimAbandonedResponseStateTemps({
1139
+ maxEntries: PERIODIC_TEMP_MAX_ENTRIES,
1140
+ maxCleanups: PERIODIC_TEMP_MAX_CLEANUPS,
1141
+ deadlineMs: PERIODIC_TEMP_SCAN_DEADLINE_MS,
1142
+ }).removed;
1143
+ }
1144
+
633
1145
  export function responseContinuationRetainedStoreSnapshot(): RetainedStoreSnapshot {
634
1146
  return {
635
1147
  count: states.size,
@@ -648,7 +1160,9 @@ export function evictOldestResponseContinuationForBudget(): number {
648
1160
  try {
649
1161
  const ref = writeResponseSpillDurably(id, {
650
1162
  createdAt: entry.createdAt,
1163
+ ...(entry.clientThreadId ? { clientThreadId: entry.clientThreadId } : {}),
651
1164
  items: entry.items,
1165
+ ...(entry.providerOutputStart !== undefined ? { providerOutputStart: entry.providerOutputStart } : {}),
652
1166
  ...(entry.providers ? { providers: entry.providers } : {}),
653
1167
  });
654
1168
  if (swapResidentForSpill(id, entry, ref)) spillCounters.writes += 1;
@@ -676,7 +1190,11 @@ function materializeEntry(
676
1190
  spillCounters.readFailures += 1;
677
1191
  const failure: PreviousResponseReplayFailure = {
678
1192
  code: "previous_response_not_found",
679
- reason: result.reason === "missing" ? "spill_missing" : "spill_corrupt",
1193
+ reason: result.reason === "missing"
1194
+ ? "spill_missing"
1195
+ : result.reason === "too_large"
1196
+ ? "spill_too_large"
1197
+ : "spill_corrupt",
680
1198
  };
681
1199
  replaceWithSpillFailure(id, entry);
682
1200
  schedulePersist();
@@ -684,7 +1202,11 @@ function materializeEntry(
684
1202
  }
685
1203
  const state = measureResidentEntry(id, {
686
1204
  createdAt: result.payload.createdAt,
1205
+ ...(result.payload.clientThreadId ? { clientThreadId: result.payload.clientThreadId } : {}),
687
1206
  items: result.payload.items,
1207
+ ...(result.payload.providerOutputStart !== undefined
1208
+ ? { providerOutputStart: result.payload.providerOutputStart }
1209
+ : {}),
688
1210
  ...(result.payload.providers ? { providers: result.payload.providers } : {}),
689
1211
  });
690
1212
  if (!state) {
@@ -696,7 +1218,16 @@ function materializeEntry(
696
1218
  return { ok: true, state };
697
1219
  }
698
1220
 
699
- export function expandPreviousResponseInput(body: unknown): unknown {
1221
+ function normalizedClientThreadId(value: unknown): string | undefined {
1222
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
1223
+ }
1224
+
1225
+ function withoutPreviousResponseId(request: Record<string, unknown>): Record<string, unknown> {
1226
+ const { previous_response_id: _previousResponseId, ...freshRequest } = request;
1227
+ return freshRequest;
1228
+ }
1229
+
1230
+ export function expandPreviousResponseInput(body: unknown, clientThreadId?: string): unknown {
700
1231
  if (!body || typeof body !== "object" || Array.isArray(body)) return body;
701
1232
  const request = body as Record<string, unknown>;
702
1233
  const previousId = typeof request.previous_response_id === "string" ? request.previous_response_id : undefined;
@@ -710,6 +1241,49 @@ export function expandPreviousResponseInput(body: unknown): unknown {
710
1241
  replayFailures.set(request, materialized.failure);
711
1242
  return body;
712
1243
  }
1244
+ const requestThreadId = normalizedClientThreadId(clientThreadId);
1245
+ const storedThreadId = normalizedClientThreadId(materialized.state.clientThreadId);
1246
+ // A Codex task must never inherit another task's continuation, nor a legacy unscoped entry.
1247
+ // Unscoped callers retain backward-compatible replay only with other unscoped entries.
1248
+ if (requestThreadId !== storedThreadId) {
1249
+ const freshRequest = withoutPreviousResponseId(request);
1250
+ replayScopeMismatches.add(freshRequest);
1251
+ replayScopeMismatchDrops += 1;
1252
+ return freshRequest;
1253
+ }
1254
+ // The client already replayed this history verbatim. Prepending the stored copy would
1255
+ // double it, and the doubled turn is stored again, so the next turn triples (#1412 saw
1256
+ // 127k of real context reach 1.3M tokens this way).
1257
+ //
1258
+ // Three conditions, all required. The run must cover the whole stored entry; it must reach
1259
+ // the provider-output region; and some matched item in that region must carry a
1260
+ // provider-issued id. The last one is the load-bearing part: content equality alone proves
1261
+ // two items look alike, not that they are the same occurrence, so a client that merely
1262
+ // repeats its own message would otherwise authorize a skip that deletes real history.
1263
+ // There is no invariant that provider output always carries ids, so an entry whose output
1264
+ // has none simply never skips.
1265
+ {
1266
+ const clientInput = inputItems(request.input);
1267
+ const stored = materialized.state.items;
1268
+ const anchor = materialized.state.providerOutputStart;
1269
+ const carried = clientCarriedPrefixLength(stored, clientInput);
1270
+ if (
1271
+ carried === stored.length
1272
+ && anchor !== undefined
1273
+ && carried > anchor
1274
+ && stored.slice(anchor, carried).some(item => providerIssuedIdentity(item) !== null)
1275
+ ) {
1276
+ replayOverlapSkips += 1;
1277
+ // Keep previous_response_id: Kiro and Cursor recover their conversation ids from it
1278
+ // (kiro-wire.ts, cursor/request-builder.ts). Only the concatenation is skipped.
1279
+ const unchanged = { ...request };
1280
+ // Same provenance boundary a real expansion would record, so the replayed prefix does
1281
+ // not re-acknowledge historical compaction markers (parser.ts) and stays visible to
1282
+ // guidance de-duplication (collaboration.ts).
1283
+ replayedInputPrefixLengths.set(unchanged, carried);
1284
+ return unchanged;
1285
+ }
1286
+ }
713
1287
  const expanded = {
714
1288
  ...request,
715
1289
  input: [...materialized.state.items, ...inputItems(request.input)],
@@ -729,6 +1303,22 @@ export function previousResponseReplayPrefixLength(body: unknown): number {
729
1303
  return replayedInputPrefixLengths.get(body) ?? 0;
730
1304
  }
731
1305
 
1306
+ /** Copy proxy-private replay provenance to an internal clone with the same materialized input. */
1307
+ export function copyPreviousResponseReplayProvenance(source: unknown, target: unknown): void {
1308
+ if (!source || typeof source !== "object" || Array.isArray(source)) return;
1309
+ if (!target || typeof target !== "object" || Array.isArray(target)) return;
1310
+ const prefixLength = replayedInputPrefixLengths.get(source);
1311
+ if (!prefixLength) return;
1312
+ const input = (target as { input?: unknown }).input;
1313
+ if (!Array.isArray(input) || prefixLength > input.length) return;
1314
+ replayedInputPrefixLengths.set(target, prefixLength);
1315
+ }
1316
+
1317
+ /** True when a stale or foreign previous_response_id was removed from this exact request body. */
1318
+ export function previousResponseScopeMismatch(body: unknown): boolean {
1319
+ return !!body && typeof body === "object" && replayScopeMismatches.has(body as object);
1320
+ }
1321
+
732
1322
  export function previousResponseConversationId(responseId: string | undefined): string | undefined {
733
1323
  return previousResponseProviderState(responseId)?.cursor?.conversationId;
734
1324
  }
@@ -754,6 +1344,7 @@ export interface ResponseStateMetrics {
754
1344
  spillWrites: number;
755
1345
  spillWriteFailures: number;
756
1346
  spillReadFailures: number;
1347
+ replayScopeMismatchDrops: number;
757
1348
  }
758
1349
 
759
1350
  /**
@@ -796,6 +1387,7 @@ export function responseStateMetrics(): ResponseStateMetrics {
796
1387
  spillWrites: spillCounters.writes,
797
1388
  spillWriteFailures: spillCounters.writeFailures,
798
1389
  spillReadFailures: spillCounters.readFailures,
1390
+ replayScopeMismatchDrops,
799
1391
  };
800
1392
  }
801
1393
 
@@ -803,14 +1395,36 @@ export function responseStateMetrics(): ResponseStateMetrics {
803
1395
  * Cache completed output and max_output_tokens partial output for previous_response_id replay.
804
1396
  * Content-filtered incomplete and failed output are not authoritative replay history.
805
1397
  */
1398
+ /**
1399
+ * Request bodies that must never enter the continuation cache.
1400
+ *
1401
+ * The cache is persisted to `responses-state.json`, so anything recorded here reaches disk.
1402
+ * Encrypted-agent-task recovery decrypts task text into the request body and promises
1403
+ * in-memory, TTL-bounded retention; recording that body would put the plaintext on disk with
1404
+ * no TTL and break the promise.
1405
+ *
1406
+ * A WeakSet rather than a body field on purpose: `_rawBody` is serialized verbatim by the
1407
+ * native passthrough, so any marker written into the body itself would be sent upstream.
1408
+ * Marking is enforced once here rather than at each call site, because every recording path
1409
+ * (streaming, non-streaming, passthrough, forced) funnels through `rememberResponseState` —
1410
+ * a new call site cannot reintroduce the leak by forgetting a guard.
1411
+ */
1412
+ const nonPersistableBodies = new WeakSet<object>();
1413
+
1414
+ /** Bar this exact request body from the continuation cache, and therefore from disk. */
1415
+ export function markBodyNonPersistable(body: unknown): void {
1416
+ if (body && typeof body === "object") nonPersistableBodies.add(body as object);
1417
+ }
1418
+
806
1419
  export function rememberResponseState(
807
1420
  requestBody: unknown,
808
1421
  response: { id?: unknown; output?: unknown; status?: unknown; incomplete_details?: unknown },
809
1422
  providerState?: OcxProviderContinuationState | string,
810
- opts?: { force?: boolean },
1423
+ opts?: { force?: boolean; clientThreadId?: string },
811
1424
  ): void {
812
1425
  if (!requestBody || typeof requestBody !== "object" || Array.isArray(requestBody)) return;
813
1426
  const request = requestBody as Record<string, unknown>;
1427
+ if (nonPersistableBodies.has(request)) return;
814
1428
  // `force` bypasses only the store:false skip: Codex sends `store:false` on every non-Azure
815
1429
  // HTTP request (and WS inherits it), yet its WS turns still chain with previous_response_id.
816
1430
  // The passthrough branch records with force so those chains can be expanded locally; the
@@ -832,9 +1446,18 @@ export function rememberResponseState(
832
1446
  return !!item && typeof item === "object" && (item as { type?: unknown }).type === "function_call";
833
1447
  });
834
1448
  }
1449
+ const clientThreadId = normalizedClientThreadId(opts?.clientThreadId);
1450
+ // Compute the normalized array once and reuse it for both fields, so the recorded
1451
+ // boundary can never disagree with the items it indexes.
1452
+ const requestItems = inputItems(request.input);
835
1453
  setResidentEntry(response.id, {
836
1454
  createdAt: now(),
837
- items: [...inputItems(request.input), ...response.output],
1455
+ ...(clientThreadId ? { clientThreadId } : {}),
1456
+ items: [...requestItems, ...response.output],
1457
+ // Where response.output begins. A replay skip requires a matched item at or past this
1458
+ // index that also carries a provider-issued id — position alone proves only that an item
1459
+ // sits on the provider side, not that the provider authored it.
1460
+ providerOutputStart: requestItems.length,
838
1461
  // Always preserve the Cursor conversation id so the next tool-result turn can continue the SAME
839
1462
  // Cursor conversation (multi-turn continuation). Separately track whether Cursor's own
840
1463
  // checkpoint/cache is safe to reuse: a turn that ended with a pending client tool call produced an
@@ -879,7 +1502,12 @@ export function clearResponseStateMemoryForTests(): void {
879
1502
  spillCounters.writes = 0;
880
1503
  spillCounters.writeFailures = 0;
881
1504
  spillCounters.readFailures = 0;
1505
+ replayScopeMismatchDrops = 0;
1506
+ replayOverlapSkips = 0;
882
1507
  persistAttemptHookForTests = null;
1508
+ lastSnapshotBytes = 0;
1509
+ lastSnapshotDigest = null;
1510
+ lastSnapshotTarget = null;
883
1511
  loaded = false;
884
1512
  }
885
1513