@tea-agent/loop-agent 0.43.0 → 0.44.0-next.10

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 (226) hide show
  1. package/CHANGELOG.md +408 -367
  2. package/dist/application/evaluation/budget.js +21 -0
  3. package/dist/application/evaluation/corpus-hash.js +10 -15
  4. package/dist/application/evaluation/corpus.js +2 -1
  5. package/dist/application/evaluation/frontend-browser-acceptance.js +77 -0
  6. package/dist/application/evaluation/frontend-corpus-browser-acceptance.js +175 -0
  7. package/dist/application/evaluation/frontend-gateway-evidence.js +226 -0
  8. package/dist/application/evaluation/frontend-pair-registration.js +143 -0
  9. package/dist/application/evaluation/frontend-paired-summary.js +150 -0
  10. package/dist/application/evaluation/frontend-run-observation.js +144 -0
  11. package/dist/application/evaluation/frontend-shared-evidence.js +105 -0
  12. package/dist/application/evaluation/types.js +45 -12
  13. package/dist/application/task-lifecycle/observe.js +26 -1
  14. package/dist/build-stamp.json +3 -3
  15. package/dist/cli/command-definitions.js +11 -0
  16. package/dist/cli/program.js +4 -0
  17. package/dist/commands/task-advance.js +3 -5
  18. package/dist/commands/task-source-prepare.js +13 -1
  19. package/dist/executors/dag-pi/guards.js +4 -0
  20. package/dist/executors/dag-pi/model-config.js +30 -0
  21. package/dist/executors/dag-pi/plan/fact-adoption.js +55 -0
  22. package/dist/{workflows/dag/frontend-plan-completeness.js → executors/dag-pi/plan/facts.js} +22 -28
  23. package/dist/executors/dag-pi/plan/finalize-tools.js +259 -0
  24. package/dist/executors/dag-pi/plan/ledger-contract.js +55 -0
  25. package/dist/executors/dag-pi/plan/record-tools.js +861 -0
  26. package/dist/executors/dag-pi/plan/schema.js +129 -0
  27. package/dist/executors/dag-pi/plan/session.js +1 -0
  28. package/dist/executors/dag-pi/plan/tool-helpers.js +16 -0
  29. package/dist/executors/dag-pi/tools/contract-tools.js +515 -0
  30. package/dist/executors/dag-pi/tools/design-terminal-tools.js +159 -0
  31. package/dist/executors/dag-pi/tools/plan-decision-tools.js +529 -0
  32. package/dist/executors/dag-pi/tools/review-terminal-tools.js +159 -0
  33. package/dist/executors/dag-pi/tools/scout-evidence-tools.js +270 -0
  34. package/dist/executors/dag-pi-executor.js +398 -3384
  35. package/dist/executors/pi-executor.js +6 -1
  36. package/dist/executors/pi-sdk-executor.js +112 -19
  37. package/dist/executors/shell-executor.js +88 -12
  38. package/dist/executors/shell-presets.js +1 -1
  39. package/dist/executors/shell-write-guard.js +26 -3
  40. package/dist/infrastructure/console/operation-store.js +48 -14
  41. package/dist/infrastructure/evaluation/corpus-store.js +37 -12
  42. package/dist/shared/file-identity.js +23 -0
  43. package/dist/shared/git-workspace-status.js +482 -0
  44. package/dist/shared/operator/capabilities.js +10 -5
  45. package/dist/shared/path-safety.js +2 -2
  46. package/dist/shared/wizard-local-telemetry.js +51 -0
  47. package/dist/task/config-types.js +22 -8
  48. package/dist/task/contract/project.js +13 -0
  49. package/dist/task/contract/schema.js +2 -7
  50. package/dist/task/frontend-preflight.js +1 -1
  51. package/dist/task/source-prepare/parse-intent.js +124 -5
  52. package/dist/task/source-prepare/prepare.js +6 -1
  53. package/dist/task/source-prepare/semantic-intake.js +18 -3
  54. package/dist/task/source-prepare/source-execution.js +65 -0
  55. package/dist/task/source-prepare/source-fidelity-pi.js +22 -9
  56. package/dist/task/source-prepare/source-provider-budget.js +290 -0
  57. package/dist/worker/console/chat/assistant-content.js +5 -34
  58. package/dist/worker/console/chat/chat-event-store.js +62 -10
  59. package/dist/worker/console/chat/context-insights.js +723 -0
  60. package/dist/worker/console/chat/conversation-nodes.js +383 -0
  61. package/dist/worker/console/chat/db-connections.js +854 -0
  62. package/dist/worker/console/chat/pi-runtime/custom-tools/operator-tools.js +1 -1
  63. package/dist/worker/console/chat/pi-runtime/custom-tools/sql-tools.js +655 -0
  64. package/dist/worker/console/chat/pi-runtime.js +445 -36
  65. package/dist/worker/console/chat/provider-error.js +0 -12
  66. package/dist/worker/console/chat/repo-browser.js +52 -4
  67. package/dist/worker/console/chat/routes.js +577 -72
  68. package/dist/worker/console/chat/sdd-data-alignment.js +8 -31
  69. package/dist/worker/console/chat/session-store.js +147 -19
  70. package/dist/worker/console/chat/shortcuts.js +7 -0
  71. package/dist/worker/console/chat/timeline-message-order.js +22 -0
  72. package/dist/worker/console/chat/tools.js +34 -0
  73. package/dist/worker/console/chat/turn-process.js +237 -66
  74. package/dist/worker/console/chat/user-turn-outline.js +19 -0
  75. package/dist/worker/console/console-update-runtime.js +2 -2
  76. package/dist/worker/console/dag-confirmation.js +3 -0
  77. package/dist/worker/console/dag-execution-receipt.js +3 -0
  78. package/dist/worker/console/operator-actions.js +32 -3
  79. package/dist/worker/console/operator-user-error.js +28 -1
  80. package/dist/worker/console/pi-readiness.js +4 -4
  81. package/dist/worker/console/prd-intake-bridge.js +5 -12
  82. package/dist/worker/console/recovery-cta.js +14 -14
  83. package/dist/worker/console/server.js +5 -1
  84. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-8-j6y-sd.js → abnfDiagram-N423BO3Z-r1NAPU_5.js} +1 -1
  85. package/dist/worker/console/static/assets/{arc-eoQiMvuk.js → arc-dT5T4sVL.js} +1 -1
  86. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-C4A3uMcI.js → architectureDiagram-T3A2C74G-CP_cOq46.js} +1 -1
  87. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-D4zD2F-Q.js → blockDiagram-VBNYF7ZC-sToEZxEf.js} +1 -1
  88. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-j1RkJziL.js → c4Diagram-5PPSVZJV-DSxwwi2m.js} +1 -1
  89. package/dist/worker/console/static/assets/channel-CyBY_yGk.js +1 -0
  90. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-YBHmhik1.js → chunk-2GRJ4B5K-B9fh0IkF.js} +1 -1
  91. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-COfWyo9P.js → chunk-2Q5K7J3B-CAUNl-A6.js} +1 -1
  92. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-I99OUkHY.js → chunk-5RXB4S5H-BomeqbNX.js} +1 -1
  93. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-XKxoJNJ4.js → chunk-5VM5RSS4-BNAuTOLE.js} +1 -1
  94. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-DLT_cYx1.js → chunk-6Q2QTUOP-DfzjRD5y.js} +1 -1
  95. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-CxcKZ9mV.js → chunk-GF5L2VYU-C3uE-yc2.js} +1 -1
  96. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-V1EqXdjY.js → chunk-JWPE2WC7-C3sQMMYr.js} +1 -1
  97. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-DebTtdFp.js → chunk-KBJHAD2P-D_AtEYQL.js} +1 -1
  98. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-B-ivNRDf.js → chunk-RYQCIY6F-DW4TwhKv.js} +1 -1
  99. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-DC_Ds11b.js → chunk-XXDRQBXY-CTQDmJcp.js} +1 -1
  100. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-D5lN4E_E.js +1 -0
  101. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-D5lN4E_E.js +1 -0
  102. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-sWEqKwIb.js → cose-bilkent-JH36ORCC-iMqPuBsL.js} +1 -1
  103. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-BXa_dcu4.js → cynefin-VYW2F7L2-DktIuDWs.js} +1 -1
  104. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-C-Mle84F.js → cynefinDiagram-MW4NZA55-BgVnjyEs.js} +1 -1
  105. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-CXPDBITe.js → dagre-VZM6K2ZE-CR25E4bu.js} +1 -1
  106. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-CC-WJQfa.js → diagram-7IWD3JNH-CPM2h5HY.js} +1 -1
  107. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-CDAV5Vs4.js → diagram-B4RE2ZJO-DqF0x2g1.js} +1 -1
  108. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-CmFiAcNz.js → diagram-LBJQPF4R-G8BYp2eb.js} +1 -1
  109. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-DPBZHuyn.js → diagram-Q27KOJAE-BbC4kT_D.js} +1 -1
  110. package/dist/worker/console/static/assets/{diagram-UB23O5K3-jUlm_Ds3.js → diagram-UB23O5K3-CKZwZ-G5.js} +1 -1
  111. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-DWhQ3mfY.js → ebnfDiagram-BXEA7PRR-kJDmheia.js} +1 -1
  112. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-Tr2gMqet.js → erDiagram-JOGREHBK-DFTVHyxB.js} +1 -1
  113. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-DJjVQHPA.js → flowDiagram-UKHOOZJN-BqdN5_os.js} +1 -1
  114. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-D74dQ4u0.js → ganttDiagram-PKOTCBZU-SYDjVakh.js} +1 -1
  115. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-DwW0tZ0X.js → gitGraphDiagram-DS77QQ5N-Bf8bNpGA.js} +1 -1
  116. package/dist/worker/console/static/assets/index-B28Onmfy.css +1 -0
  117. package/dist/worker/console/static/assets/index-CTMZsDmh.js +486 -0
  118. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-ILCbxJyb.js → infoDiagram-6WML65LV-DIeAmJ45.js} +1 -1
  119. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-DeuWBQ89.js → ishikawaDiagram-WSZJBQD7-oikqUAlo.js} +1 -1
  120. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-CF5ih8Fk.js → journeyDiagram-NVQOT4AX-mKEF_xYT.js} +1 -1
  121. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-C7yOSuRO.js → kanban-definition-27J2QSJJ-BdWJHPnN.js} +1 -1
  122. package/dist/worker/console/static/assets/{linear-BDZ9riWi.js → linear-DGRlieMH.js} +1 -1
  123. package/dist/worker/console/static/assets/{mermaid.core-7pKqYtpZ.js → mermaid.core-4P9yPC9c.js} +5 -5
  124. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-LeJDybSU.js → mindmap-definition-FAOFIHXS-5PTSkos6.js} +1 -1
  125. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-BJvT3pMD.js → pegDiagram-VL7TDLO6-DONIFpG5.js} +1 -1
  126. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-_rGqpMan.js → pieDiagram-7S7Q4E2Y-D_Z4S70M.js} +1 -1
  127. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-DPcSv5aA.js → quadrantDiagram-CIZ2JOQS-CAsjh8iY.js} +1 -1
  128. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-Drxx4hkJ.js → railroadDiagram-AXF67PYL-Cx4Dd6ac.js} +1 -1
  129. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-BCTUdU4z.js → requirementDiagram-LRYGKXZP-cQb7mwyH.js} +1 -1
  130. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-B6wzbZmB.js → sankeyDiagram-W5VNT64P-Cd-DhUdf.js} +1 -1
  131. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-BGt8d3QQ.js → sequenceDiagram-SI44F4Z6-AlKYRgIk.js} +1 -1
  132. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-7nlhJKo7.js → sizeCapture-X5ZJPWSS-CUy_ET4e.js} +1 -1
  133. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-Cv2stqMA.js → stateDiagram-OKZ733FA-CTuVe6Sa.js} +1 -1
  134. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-BsWhs8O7.js +1 -0
  135. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-qDAo4Yc1.js → swimlanes-SLNWSIFB-ChFLJb-v.js} +2 -2
  136. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-CJcioYGH.js +8 -0
  137. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-CKbDNKTr.js → timeline-definition-Z64GVDOM-DZZeiAPr.js} +1 -1
  138. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-DI-9EHic.js → vennDiagram-T6HMQDX7-t4O91cZU.js} +1 -1
  139. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-BrzzRDpX.js → wardleyDiagram-T6FBY63Y-CV5qGHZu.js} +1 -1
  140. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-BclH5hGh.js → xychartDiagram-ELKLHX3M-DyXbytUI.js} +1 -1
  141. package/dist/worker/console/static/index.html +2 -2
  142. package/dist/worker/console/static-src/app/console-types.js +9 -1
  143. package/dist/worker/console/static-src/operator-chat/animation-frame-batcher.js +38 -0
  144. package/dist/worker/console/static-src/operator-chat/chat-link.js +20 -0
  145. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +186 -5
  146. package/dist/worker/console/static-src/operator-chat/chat-stream-continuity.js +101 -0
  147. package/dist/worker/console/static-src/operator-chat/compaction-message.js +18 -3
  148. package/dist/worker/console/static-src/operator-chat/context-insights-store.js +64 -0
  149. package/dist/worker/console/static-src/operator-chat/details-rail-surfaces.js +98 -0
  150. package/dist/worker/console/static-src/operator-chat/file-palette-nav.js +81 -0
  151. package/dist/worker/console/static-src/operator-chat/pending-user-message.js +17 -3
  152. package/dist/worker/console/static-src/operator-chat/process-label.js +27 -5
  153. package/dist/worker/console/static-src/operator-chat/tools-catalog.js +16 -7
  154. package/dist/worker/console/static-src/operator-chat/transcript-mode.js +21 -0
  155. package/dist/worker/console/static-src/operator-chat/turn-group-equality.js +4 -1
  156. package/dist/worker/console/static-src/operator-chat/turn-process-disclosure.js +13 -5
  157. package/dist/worker/console/static-src/operator-chat/turn-state-projection.js +166 -0
  158. package/dist/worker/console/static-src/operator-chat/turn-stream-controller.js +56 -2
  159. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +211 -40
  160. package/dist/worker/console/static-src/operator-chat/useChatStream.js +124 -27
  161. package/dist/worker/console/static-src/operator-chat/useChatThread.js +88 -20
  162. package/dist/worker/console/static-src/operator-chat/useComposer.js +80 -6
  163. package/dist/worker/console/static-src/operator-chat/useRepoBrowser.js +13 -3
  164. package/dist/worker/console/static-src/operator-chat/useRuntimeControls.js +36 -17
  165. package/dist/worker/console/static-src/operator-chat/useRuntimeSnapshot.js +18 -0
  166. package/dist/worker/console/static-src/operator-chat/useWorkspaceBrowserGate.js +4 -0
  167. package/dist/worker/console/static-src/shell/console-update-reload.js +1 -1
  168. package/dist/worker/console/workspace-context.js +2 -0
  169. package/dist/worker/console/workspace-initialization.js +3 -3
  170. package/dist/worker/observability/read-model.js +23 -2
  171. package/dist/worker/observe/node-context-insights.js +479 -0
  172. package/dist/worker/observe/static/console-theme.css +174 -0
  173. package/dist/worker/observe/static/console-theme.js +36 -0
  174. package/dist/worker/observe/static/operator-chrome.css +85 -0
  175. package/dist/worker/observe/static/operator-chrome.js +51 -0
  176. package/dist/worker/scheduler/scheduled-goal-dispatch.js +3 -2
  177. package/dist/worker/scheduler/scheduled-goal-evidence.js +66 -3
  178. package/dist/worker/scheduler/scheduled-goal-store.js +10 -0
  179. package/dist/workflows/dag/budget-enforcement.js +31 -2
  180. package/dist/workflows/dag/frontend-closeout.js +3 -1
  181. package/dist/workflows/dag/frontend-contract-facts.js +11 -8
  182. package/dist/workflows/dag/frontend-design-policy.js +6 -6
  183. package/dist/workflows/dag/frontend-durable-tools.js +90 -70
  184. package/dist/workflows/dag/frontend-implementation-contract.js +146 -57
  185. package/dist/workflows/dag/frontend-plan-canary.js +66 -16
  186. package/dist/workflows/dag/frontend-plan-decision-contract.js +13 -21
  187. package/dist/workflows/dag/frontend-plan-progress.js +92 -0
  188. package/dist/workflows/dag/frontend-recovery-plan.js +10 -5
  189. package/dist/workflows/dag/frontend-recovery-run.js +51 -46
  190. package/dist/workflows/dag/frontend-repair-assertions.js +36 -0
  191. package/dist/workflows/dag/frontend-review-context.js +29 -1
  192. package/dist/workflows/dag/frontend-review-scopes.js +92 -2
  193. package/dist/workflows/dag/frontend-risk.js +8 -3
  194. package/dist/workflows/dag/frontend-root-observation.js +261 -0
  195. package/dist/workflows/dag/frontend-session-budget.js +52 -39
  196. package/dist/workflows/dag/frontend-session-context.js +10 -0
  197. package/dist/workflows/dag/frontend-test-execution-evidence.js +206 -48
  198. package/dist/workflows/dag/frontend-typed-event-store.js +11 -1
  199. package/dist/workflows/dag/frontend-verification-trace.js +6 -0
  200. package/dist/workflows/dag/frontend-writer-admission.js +3 -1
  201. package/dist/workflows/dag/init-hybrid.js +108 -37
  202. package/dist/workflows/dag/node-execution.js +11 -11
  203. package/dist/workflows/dag/output-protocol.js +1 -0
  204. package/dist/workflows/dag/rerun-feedback.js +35 -4
  205. package/dist/workflows/dag/rerun-task.js +62 -66
  206. package/dist/workflows/dag/runner.js +58 -12
  207. package/dist/workflows/dag/types.js +15 -8
  208. package/dist/workflows/loop/benchmark.js +11 -8
  209. package/docs/README.md +1 -1
  210. package/docs/architecture/runtime-boundaries.md +1 -1
  211. package/docs/governance/harness-methodology-verification.md +11 -0
  212. package/docs/templates/backend-test-dag.json +5 -4
  213. package/docs/templates/frontend-implementation-contract.schema.json +31 -11
  214. package/package.json +6 -4
  215. package/skills/frontend-design-review/SKILL.md +8 -11
  216. package/skills/frontend-design-review/references/review-checklist.md +3 -4
  217. package/skills/frontend-review/SKILL.md +5 -1
  218. package/skills/loop-agent/references/command-reference.md +8 -0
  219. package/skills/loop-agent/references/hybrid-dag.md +1 -1
  220. package/dist/worker/console/static/assets/channel-ChE7y-cx.js +0 -1
  221. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-C01TCf2X.js +0 -1
  222. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-C01TCf2X.js +0 -1
  223. package/dist/worker/console/static/assets/index-BWkIfcrK.css +0 -1
  224. package/dist/worker/console/static/assets/index-Cdkvw_H6.js +0 -469
  225. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DC7V5vcq.js +0 -1
  226. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-Bwy4QUTO.js +0 -8
@@ -0,0 +1,290 @@
1
+ import { completedSourceExecutionSchema } from "./source-execution.js";
2
+ import { currentFrontendSessionIdentity } from "../../workflows/dag/frontend-session-context.js";
3
+ import { sha256OfCanonicalJson } from "../contract/hash.js";
4
+ import { createHash, randomUUID } from "node:crypto";
5
+ import { lstat, mkdir, readdir, rmdir } from "node:fs/promises";
6
+ import path from "node:path";
7
+ import { sourceProviderBudgetSchema as ledgerSchema } from "../../application/evaluation/budget.js";
8
+ import { writeJsonAtomic } from "../../infrastructure/harness/atomic-write.js";
9
+ import { readFileBoundarySafe } from "../../shared/path-safety.js";
10
+ import { getTaskPaths, loadTaskConfig } from "../runtime.js";
11
+ /** Read-only generation snapshot; never create a zero ledger over old receipts. */
12
+ export async function readTaskSourceProviderBudget(input) {
13
+ const root = path.join(getTaskPaths(input.repoRoot, input.taskId).taskDir, "artifacts");
14
+ const file = path.join(root, "source-provider-budget.json");
15
+ const exists = await lstat(file).then(() => true).catch(error => { if (error.code === "ENOENT")
16
+ return false; throw error; });
17
+ if (!exists) {
18
+ const files = await readdir(path.join(root, "session-budget")).catch(error => { if (error.code === "ENOENT")
19
+ return []; throw error; });
20
+ if (files.length || input.expectedBudgetId)
21
+ throw Error("FRONTEND_PROVIDER_BUDGET_INVALID: missing bound source budget");
22
+ return undefined;
23
+ }
24
+ const ledger = ledgerSchema.parse(JSON.parse((await readFileBoundarySafe({ boundaryRoot: root, targetPath: file, label: "source provider budget" })).toString("utf8")));
25
+ validateBinding(input, ledger);
26
+ return ledger;
27
+ }
28
+ function validateBinding(input, ledger) {
29
+ if (ledger.taskId !== input.taskId || ledger.maxProviderRequests !== input.maxProviderRequests || input.expectedBudgetId && ledger.budgetId !== input.expectedBudgetId || input.minimumReservedRequests !== undefined && ledger.reservedRequests < input.minimumReservedRequests)
30
+ throw Error("FRONTEND_PROVIDER_BUDGET_INVALID: source budget binding changed");
31
+ }
32
+ /** The short filesystem critical section spans read, admission and atomic
33
+ * persistence. A stale lock is never stolen on an age heuristic. */
34
+ async function transact(input, reserve) {
35
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(input.taskId) || !Number.isSafeInteger(input.maxProviderRequests) || input.maxProviderRequests < 1)
36
+ throw Error("FRONTEND_PROVIDER_BUDGET_INVALID: invalid source budget identity or limit");
37
+ await loadTaskConfig(input.repoRoot, input.taskId); // never recreate a missing task
38
+ const root = path.join(getTaskPaths(input.repoRoot, input.taskId).taskDir, "artifacts");
39
+ const rootStat = await lstat(root);
40
+ if (!rootStat.isDirectory() || rootStat.isSymbolicLink())
41
+ throw Error("FRONTEND_PROVIDER_BUDGET_INVALID: source artifact directory is not a real directory");
42
+ const file = path.join(root, "source-provider-budget.json");
43
+ const lock = path.join(root, ".source-provider-budget.lock");
44
+ let held = false;
45
+ try {
46
+ for (let attempt = 0; attempt < 100; attempt++) {
47
+ try {
48
+ await mkdir(lock);
49
+ held = true;
50
+ break;
51
+ }
52
+ catch (error) {
53
+ if (error.code !== "EEXIST")
54
+ throw error;
55
+ await new Promise(resolve => setTimeout(resolve, 10));
56
+ }
57
+ }
58
+ if (!held)
59
+ throw Error("FRONTEND_PROVIDER_BUDGET_BUSY: source budget lock unavailable");
60
+ let ledger;
61
+ try {
62
+ await lstat(file);
63
+ ledger = ledgerSchema.parse(JSON.parse((await readFileBoundarySafe({ boundaryRoot: root, targetPath: file, label: "source provider budget" })).toString("utf8")));
64
+ }
65
+ catch (error) {
66
+ if (error.code !== "ENOENT")
67
+ throw Error(`FRONTEND_PROVIDER_BUDGET_INVALID: ${error instanceof Error ? error.message : String(error)}`);
68
+ if (input.expectedBudgetId)
69
+ throw Error("FRONTEND_PROVIDER_BUDGET_INVALID: bound task budget is missing");
70
+ // Existing attempts cannot be silently imported as zero-cost history.
71
+ const previous = await readdir(path.join(root, "session-budget")).catch(error => {
72
+ if (error.code === "ENOENT")
73
+ return [];
74
+ throw error;
75
+ });
76
+ if (previous.length)
77
+ throw Error("FRONTEND_PROVIDER_BUDGET_INVALID: source receipts have no bound budget");
78
+ ledger = { schemaVersion: 1, taskId: input.taskId, budgetId: randomUUID(), maxProviderRequests: input.maxProviderRequests, reservedRequests: 0 };
79
+ }
80
+ validateBinding(input, ledger);
81
+ if (reserve) {
82
+ if (ledger.reservedRequests >= ledger.maxProviderRequests)
83
+ throw Error("FRONTEND_PROVIDER_BUDGET_EXHAUSTED: source request quota reached");
84
+ const identity = currentFrontendSessionIdentity();
85
+ if (identity) {
86
+ if (identity.executionKind !== "model" || identity.taskId && identity.taskId !== input.taskId)
87
+ throw Error("FRONTEND_PROVIDER_BUDGET_INVALID: request belongs to another session/task");
88
+ const key = `${identity.runId ?? "task"}:${identity.sessionId}`;
89
+ const previous = ledger.requestSessions?.[key];
90
+ if (previous && sha256OfCanonicalJson(previous.identity) !== sha256OfCanonicalJson(identity))
91
+ throw Error("FRONTEND_PROVIDER_BUDGET_INVALID: session identity changed");
92
+ ledger.requestSessions ??= {};
93
+ ledger.requestSessions[key] = { identity, reservedRequests: (previous?.reservedRequests ?? 0) + 1 };
94
+ }
95
+ ledger.reservedRequests++;
96
+ }
97
+ // A failed write grants no admission. A successful reservation is never refunded.
98
+ await writeJsonAtomic(file, ledger, { repoRoot: input.repoRoot });
99
+ return ledger;
100
+ }
101
+ finally {
102
+ if (held)
103
+ await rmdir(lock);
104
+ }
105
+ }
106
+ export async function reserveTaskSourceProviderRequest(input) {
107
+ try {
108
+ await transact(input, true);
109
+ }
110
+ catch (error) {
111
+ const message = error instanceof Error ? error.message : String(error);
112
+ throw Error(message.startsWith("FRONTEND_PROVIDER_") ? message : `FRONTEND_PROVIDER_BUDGET_PERSIST_FAILED: ${message}`);
113
+ }
114
+ }
115
+ /** Initialize before the adapter writes its first incomplete session receipt. */
116
+ export async function openTaskSourceProviderBudget(input) {
117
+ await transact(input, false);
118
+ return () => reserveTaskSourceProviderRequest(input);
119
+ }
120
+ /** Check the closed set of source sessions registered by durable admission.
121
+ * Receipt copies deduplicate only when their contents are identical. */
122
+ export function summarizeBoundSourceUsage(ledger, receipts) {
123
+ ledgerSchema.parse(ledger);
124
+ const diagnostics = [];
125
+ const sessions = Object.values(ledger.requestSessions ?? {});
126
+ const sources = sessions.filter(session => session.identity.runId === null);
127
+ const unattributedRequests = ledger.reservedRequests - sessions.reduce((sum, session) => sum + session.reservedRequests, 0);
128
+ if (unattributedRequests)
129
+ diagnostics.push("unattributed-task-requests");
130
+ const byId = new Map();
131
+ for (const receipt of receipts) {
132
+ if (receipt.identity.runId !== null)
133
+ continue;
134
+ const key = receipt.identity.sessionId;
135
+ const previous = byId.get(key);
136
+ if (previous && sha256OfCanonicalJson(previous) !== sha256OfCanonicalJson(receipt))
137
+ diagnostics.push(`conflicting-source-receipt:${key}`);
138
+ byId.set(key, receipt);
139
+ if (!sources.some(session => session.identity.sessionId === key))
140
+ diagnostics.push(`unbound-source-receipt:${key}`);
141
+ }
142
+ let totalTokens = 0;
143
+ for (const session of sources) {
144
+ const receipt = byId.get(session.identity.sessionId);
145
+ if (!receipt || session.identity.taskId !== ledger.taskId || !session.identity.sourceDigest || sha256OfCanonicalJson(session.identity) !== sha256OfCanonicalJson(receipt.identity)) {
146
+ diagnostics.push(`source-session-binding:${session.identity.sessionId}`);
147
+ continue;
148
+ }
149
+ if (receipt.completion !== "complete" || !receipt.terminal || receipt.actual.requestCount !== session.reservedRequests)
150
+ diagnostics.push(`source-session-incomplete:${session.identity.sessionId}`);
151
+ const tokens = receipt.actual.totalTokens;
152
+ if (tokens === null || !Number.isFinite(tokens) || tokens < 0)
153
+ diagnostics.push(`source-tokens-unknown:${session.identity.sessionId}`);
154
+ else
155
+ totalTokens += tokens;
156
+ if (receipt.attempts.length && (receipt.attempts.some(attempt => attempt.completion !== "complete" || attempt.actual?.requestCount == null || attempt.actual?.totalTokens == null) || receipt.attempts.reduce((sum, attempt) => sum + (attempt.actual?.requestCount ?? 0), 0) !== session.reservedRequests || receipt.attempts.reduce((sum, attempt) => sum + (attempt.actual?.totalTokens ?? 0), 0) !== tokens))
157
+ diagnostics.push(`source-attempt-usage-mismatch:${session.identity.sessionId}`);
158
+ }
159
+ return { complete: diagnostics.length === 0, diagnostics, sourceSessions: sources.length, sourceRequests: sources.reduce((sum, session) => sum + session.reservedRequests, 0), unattributedRequests, totalTokens: diagnostics.length ? null : totalTokens };
160
+ }
161
+ const receiptHash = (bytes) => createHash("sha256").update(bytes).digest("hex");
162
+ /** Freeze actual bytes, not a caller-supplied token total. Concurrent changes
163
+ * remain diagnostic evidence rather than a cheaper snapshot. */
164
+ export async function freezeTaskSourceUsage(input, ledger) {
165
+ const root = path.join(getTaskPaths(input.repoRoot, input.taskId).taskDir, "artifacts");
166
+ const evidence = { receipts: [], executions: [], diagnostics: [] };
167
+ try {
168
+ const files = await readdir(path.join(root, "session-budget")).catch(error => { if (error.code === "ENOENT")
169
+ return []; throw error; });
170
+ for (const name of files.sort()) {
171
+ const relative = `session-budget/${name}`;
172
+ const bytes = await readFileBoundarySafe({ boundaryRoot: root, targetPath: path.join(root, relative), label: "source usage receipt" });
173
+ evidence.receipts.push({ path: relative, sha256: receiptHash(bytes), content: bytes.toString("utf8") });
174
+ }
175
+ const executionIds = new Set(Object.values(ledger.requestSessions ?? {}).filter(row => row.identity.runId === null).map(row => row.identity.sourceExecutionId).filter((id) => !!id));
176
+ for (const id of [...executionIds].sort()) {
177
+ const relative = `source-executions/${id}.json`;
178
+ const bytes = await readFileBoundarySafe({ boundaryRoot: root, targetPath: path.join(root, relative), label: "source execution" });
179
+ evidence.executions.push({ path: relative, sha256: receiptHash(bytes), content: bytes.toString("utf8") });
180
+ }
181
+ for (const ref of [...evidence.receipts, ...evidence.executions]) {
182
+ const bytes = await readFileBoundarySafe({ boundaryRoot: root, targetPath: path.join(root, ref.path), label: "source usage receipt" });
183
+ if (receiptHash(bytes) !== ref.sha256)
184
+ throw Error("source-receipt-changed-during-freeze");
185
+ }
186
+ const after = await readTaskSourceProviderBudget(input);
187
+ if (sha256OfCanonicalJson(after ?? null) !== sha256OfCanonicalJson(ledger))
188
+ throw Error("source-ledger-changed-during-freeze");
189
+ }
190
+ catch (error) {
191
+ evidence.diagnostics.push(error instanceof Error ? error.message : String(error));
192
+ }
193
+ return evidence;
194
+ }
195
+ export function summarizeFrozenSourceUsage(binding) {
196
+ const diagnostics = [...(binding.evidence?.diagnostics ?? [])];
197
+ const receipts = [];
198
+ if (!binding.evidence)
199
+ diagnostics.push("missing-frozen-source-evidence");
200
+ for (const ref of binding.evidence?.receipts ?? []) {
201
+ try {
202
+ if (!/^session-budget\/[^/\\]+\.json$/.test(ref.path) || receiptHash(ref.content) !== ref.sha256)
203
+ throw Error("source-receipt-hash-or-path");
204
+ const receipt = JSON.parse(ref.content);
205
+ if (receipt.schemaVersion !== 2 || receipt.policyVersion !== "frontend-session-observation-v2" || !receipt.identity || receipt.identity.runId !== null || !receipt.actual || !Array.isArray(receipt.attempts) || !Number.isFinite(Date.parse(receipt.startedAt)) || !receipt.finishedAt || Date.parse(receipt.finishedAt) < Date.parse(receipt.startedAt) || !Number.isFinite(Date.parse(receipt.finishedAt)))
206
+ throw Error("invalid-source-receipt");
207
+ receipts.push(receipt);
208
+ }
209
+ catch {
210
+ diagnostics.push(`invalid-frozen-source-receipt:${ref.path}`);
211
+ }
212
+ }
213
+ const executions = new Map();
214
+ const required = new Set(Object.values(binding.ledger.requestSessions ?? {}).filter(row => row.identity.runId === null).map(row => row.identity.sourceExecutionId).filter((id) => !!id));
215
+ for (const ref of binding.evidence?.executions ?? []) {
216
+ try {
217
+ if (receiptHash(ref.content) !== ref.sha256)
218
+ throw Error("execution hash");
219
+ const execution = completedSourceExecutionSchema.parse(JSON.parse(ref.content));
220
+ if (ref.path !== `source-executions/${execution.executionId}.json` || execution.taskId !== binding.ledger.taskId || !required.has(execution.executionId) || executions.has(execution.executionId))
221
+ throw Error("execution identity");
222
+ executions.set(execution.executionId, execution);
223
+ }
224
+ catch {
225
+ diagnostics.push(`invalid-source-execution:${ref.path}`);
226
+ }
227
+ }
228
+ for (const id of required)
229
+ if (!executions.has(id))
230
+ diagnostics.push(`missing-source-execution:${id}`);
231
+ for (const receipt of receipts) {
232
+ const id = receipt.identity.sourceExecutionId;
233
+ if (!id) {
234
+ diagnostics.push(`unregistered-source-execution:${receipt.identity.sessionId}`);
235
+ continue;
236
+ }
237
+ const execution = executions.get(id);
238
+ if (!execution || Date.parse(receipt.startedAt) < Date.parse(execution.startedAt) || Date.parse(receipt.finishedAt) > Date.parse(execution.finishedAt) || (execution.phase === "semantic-intake") !== (receipt.identity.phase === "semantic-intake"))
239
+ diagnostics.push(`source-execution-session-mismatch:${receipt.identity.sessionId}`);
240
+ }
241
+ const relation = (snapshot) => {
242
+ if (!binding.contract)
243
+ return "unbound";
244
+ if (snapshot.revision === null && snapshot.canonicalHash === null && snapshot.observedCanonicalHash === null)
245
+ return "pre-contract";
246
+ if (snapshot.revision === null || snapshot.canonicalHash === null || snapshot.observedCanonicalHash !== snapshot.canonicalHash)
247
+ return "conflict";
248
+ if (snapshot.revision > binding.contract.revision)
249
+ return "future-revision";
250
+ if (snapshot.revision < binding.contract.revision)
251
+ return "prior-revision";
252
+ return snapshot.canonicalHash === binding.contract.canonicalHash ? "current" : "conflict";
253
+ };
254
+ const executionContracts = [...executions.values()].map(execution => ({ executionId: execution.executionId, before: relation(execution.beforeContract), after: relation(execution.afterContract) }));
255
+ for (const row of executionContracts)
256
+ if ([row.before, row.after].some(value => ["unbound", "conflict", "future-revision"].includes(value)))
257
+ diagnostics.push(`source-execution-contract:${row.executionId}`);
258
+ try {
259
+ const usage = summarizeBoundSourceUsage(binding.ledger, receipts);
260
+ diagnostics.push(...usage.diagnostics);
261
+ // Non-source requests in a task-lifetime ledger have not been priced by
262
+ // this source-only proof. Keep their existing unknown-cost marker.
263
+ if (binding.ledger.reservedRequests !== usage.sourceRequests)
264
+ diagnostics.push("non-source-task-cost-unproven");
265
+ return { ...usage, executionContracts, complete: diagnostics.length === 0, diagnostics, totalTokens: diagnostics.length ? null : usage.totalTokens };
266
+ }
267
+ catch {
268
+ return { executionContracts, complete: false, diagnostics: [...diagnostics, "invalid-source-usage"], sourceSessions: null, sourceRequests: null, unattributedRequests: null, totalTokens: null };
269
+ }
270
+ }
271
+ /** Evaluation checks the original files against the frozen generation bytes.
272
+ * It never reads today's task ledger as yesterday's cost. */
273
+ export async function verifyFrozenSourceUsage(repoRoot, binding) {
274
+ const summary = summarizeFrozenSourceUsage(binding);
275
+ const diagnostics = [...summary.diagnostics];
276
+ const root = path.join(getTaskPaths(repoRoot, binding.ledger.taskId).taskDir, "artifacts");
277
+ for (const ref of [...(binding.evidence?.receipts ?? []), ...(binding.evidence?.executions ?? [])]) {
278
+ try {
279
+ if (!/^(?:session-budget|source-executions)\/[^/\\]+\.json$/.test(ref.path))
280
+ throw Error("invalid path");
281
+ const bytes = await readFileBoundarySafe({ boundaryRoot: root, targetPath: path.join(root, ref.path), label: "frozen source usage" });
282
+ if (receiptHash(bytes) !== ref.sha256)
283
+ throw Error("changed receipt");
284
+ }
285
+ catch {
286
+ diagnostics.push(`source-evidence-missing-or-changed:${ref.path}`);
287
+ }
288
+ }
289
+ return { ...summary, complete: diagnostics.length === 0, diagnostics, totalTokens: diagnostics.length ? null : summary.totalTokens };
290
+ }
@@ -102,43 +102,14 @@ export function splitFinalAssistantBlocks(blocks, options = {}) {
102
102
  // after text that is already visible. Keep the whole live message in the
103
103
  // chronological process subtree so that update appends the tool in place
104
104
  // instead of reparenting the earlier text from answer -> process.
105
- if (options.isStreaming) {
106
- return { answerBlocks: [], processBlocks: displayable };
107
- }
108
- let lastProcessIndex = -1;
109
- for (let i = displayable.length - 1; i >= 0; i--) {
110
- if (!isFinalAnswerBlock(displayable[i])) {
111
- lastProcessIndex = i;
112
- break;
113
- }
114
- }
115
- if (lastProcessIndex === -1) {
116
- return { answerBlocks: displayable, processBlocks: [] };
117
- }
118
- const trailing = displayable.slice(lastProcessIndex + 1);
119
- if (trailing.length > 0) {
120
- return {
121
- answerBlocks: trailing,
122
- processBlocks: displayable.slice(0, lastProcessIndex + 1),
123
- };
124
- }
125
- // The stage ends on a non-text block (a tool call after the prose, or a final
126
- // thinking block). Returning an empty answer here hid the whole reply inside
127
- // the collapsed process subtree; keep the last prose block as the visible
128
- // answer instead (2026-09-08).
129
- let lastTextIndex = -1;
130
- for (let i = displayable.length - 1; i >= 0; i--) {
131
- if (isFinalAnswerBlock(displayable[i])) {
132
- lastTextIndex = i;
133
- break;
134
- }
135
- }
136
- if (lastTextIndex === -1) {
105
+ if (options.isStreaming ||
106
+ displayable.some((block) => block.type === "toolCall")) {
137
107
  return { answerBlocks: [], processBlocks: displayable };
138
108
  }
109
+ const answerBlocks = displayable.filter((block) => isFinalAnswerBlock(block) && block.text.trim() !== "");
139
110
  return {
140
- answerBlocks: [displayable[lastTextIndex]],
141
- processBlocks: displayable.filter((_block, index) => index !== lastTextIndex),
111
+ answerBlocks,
112
+ processBlocks: displayable.filter((block) => !isFinalAnswerBlock(block)),
142
113
  };
143
114
  }
144
115
  export function joinAssistantText(blocks) {
@@ -327,18 +327,62 @@ export function projectChatToolEventPayload(input) {
327
327
  toolName: input.toolName,
328
328
  argsPreview: preview,
329
329
  previewHash,
330
+ ...(input.step === undefined ? {} : { step: input.step }),
331
+ ...(input.parentToolCallId
332
+ ? { parentToolCallId: input.parentToolCallId }
333
+ : {}),
330
334
  };
331
335
  }
332
336
  const { preview, previewHash } = projectToolResultPreview(input.value);
337
+ if (input.kind === "tool_update") {
338
+ return {
339
+ toolCallId: input.toolCallId,
340
+ toolName: input.toolName,
341
+ resultPreview: preview,
342
+ previewHash,
343
+ ...(input.step === undefined ? {} : { step: input.step }),
344
+ ...(input.parentToolCallId
345
+ ? { parentToolCallId: input.parentToolCallId }
346
+ : {}),
347
+ };
348
+ }
333
349
  return {
334
350
  toolCallId: input.toolCallId,
335
351
  toolName: input.toolName,
336
352
  resultPreview: preview,
337
353
  previewHash,
338
354
  isError: Boolean(input.isError),
355
+ ...(input.step === undefined ? {} : { step: input.step }),
356
+ ...(input.parentToolCallId
357
+ ? { parentToolCallId: input.parentToolCallId }
358
+ : {}),
339
359
  ...(input.operationId ? { operationId: input.operationId } : {}),
340
360
  };
341
361
  }
362
+ export function projectUnknownChatEventPayload(input) {
363
+ const redact = (value, depth) => {
364
+ if (depth >= 6)
365
+ return "[DEPTH_LIMIT]";
366
+ if (Array.isArray(value)) {
367
+ return value.slice(0, 50).map((item) => redact(item, depth + 1));
368
+ }
369
+ if (!value || typeof value !== "object")
370
+ return value;
371
+ const result = {};
372
+ for (const [key, child] of Object.entries(value).slice(0, 100)) {
373
+ result[key] = /authorization|cookie|password|secret|token|api[-_]?key/i.test(key)
374
+ ? "[REDACTED]"
375
+ : redact(child, depth + 1);
376
+ }
377
+ return result;
378
+ };
379
+ const { preview, previewHash } = boundedRedactedJson(redact(input.value, 0));
380
+ return {
381
+ eventType: input.eventType.slice(0, 120),
382
+ preview,
383
+ previewHash,
384
+ };
385
+ }
342
386
  export function projectOperationEventSummary(event) {
343
387
  const messagePreview = boundedRedactedText(event.message, 600);
344
388
  const dataPreview = event.data === undefined
@@ -406,22 +450,27 @@ function dropOneByPriority(ring) {
406
450
  return events.shift();
407
451
  }
408
452
  /**
409
- * D2 coalescing decision: merge an incoming `message_update` into the ring tail
410
- * only when the tail is also a same-turn `message_update` and the update landed
411
- * inside the coalescing window. Structural events close the window implicitly
412
- * (their presence makes the tail check fail), so an
413
- * update → tool_call → update sequence never merges across the tool boundary.
453
+ * D2 coalescing decision: merge high-frequency cumulative text/tool updates
454
+ * into a matching ring tail inside the coalescing window. Structural events
455
+ * close the window implicitly (their presence makes the tail check fail).
414
456
  * `partial.at` (when present) drives the clock so tests can inject window
415
457
  * boundaries deterministically without fake timers.
416
458
  */
417
459
  function shouldCoalesce(ring, turnId, partial) {
418
- if (partial.kind !== "message_update")
419
- return false;
420
460
  const tail = ring.events.at(-1);
421
- if (!tail || tail.kind !== "message_update")
461
+ if (!tail || tail.kind !== partial.kind)
422
462
  return false;
423
463
  if (tail.turnId !== turnId)
424
464
  return false;
465
+ if (partial.kind === "tool_update") {
466
+ const currentId = partial.data.toolCallId;
467
+ const tailId = tail.data.toolCallId;
468
+ if (!currentId || currentId !== tailId)
469
+ return false;
470
+ }
471
+ else if (partial.kind !== "message_update") {
472
+ return false;
473
+ }
425
474
  const nowMs = partial.at ? Date.parse(partial.at) : Date.now();
426
475
  if (Number.isNaN(nowMs))
427
476
  return false;
@@ -1016,12 +1065,12 @@ export function createChatEventStore(options) {
1016
1065
  ring.approxBytes += byteHint(live);
1017
1066
  }
1018
1067
  enforceBounds(sessionId, ring);
1019
- // B5-1: only high-frequency `message_update` appends defer the persist;
1068
+ // B5-1: high-frequency cumulative updates defer the persist;
1020
1069
  // every other kind (message_end / tool_call / error / turn signals …)
1021
1070
  // still persists synchronously. Because persistNow writes the FULL
1022
1071
  // in-memory ring, one flush also lands any earlier pending updates in
1023
1072
  // order — no two-phase flush is needed.
1024
- if (partial.kind === "message_update") {
1073
+ if (partial.kind === "message_update" || partial.kind === "tool_update") {
1025
1074
  const atMs = partial.at ? Date.parse(partial.at) : Date.now();
1026
1075
  if (!Number.isNaN(atMs)) {
1027
1076
  persistDebounced(sessionId, ring, atMs);
@@ -1074,6 +1123,9 @@ export function createChatEventStore(options) {
1074
1123
  epoch(sessionId) {
1075
1124
  return ringFor(sessionId).epoch;
1076
1125
  },
1126
+ latestSeq(sessionId) {
1127
+ return ringFor(sessionId).nextSeq - 1;
1128
+ },
1077
1129
  snapshot(sessionId) {
1078
1130
  const ring = rings.get(sessionId) ?? loadRing(sessionId);
1079
1131
  if (ring)