chainlesschain 0.162.189 → 0.162.197

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 (317) hide show
  1. package/README.md +84 -11
  2. package/bin/chainlesschain.js +7 -52
  3. package/completions/_cc +209 -0
  4. package/completions/cc.bash +18 -0
  5. package/completions/cc.fish +13 -0
  6. package/completions/cc.ps1 +226 -0
  7. package/package.json +10 -2
  8. package/src/assets/web-panel/assets/{AIOps-DbZft8w0.js → AIOps-D3Gu1a5d.js} +1 -1
  9. package/src/assets/web-panel/assets/{ActionButton-DoOZpwfN.js → ActionButton-DrwAoKaz.js} +1 -1
  10. package/src/assets/web-panel/assets/{Analytics-B0J0KLTW.js → Analytics-C7Q-gd_3.js} +3 -3
  11. package/src/assets/web-panel/assets/{AppLayout-D9eIR0qq.js → AppLayout-DgBtae-n.js} +4 -4
  12. package/src/assets/web-panel/assets/{Artifacts-BnZXEVeo.js → Artifacts-pQ6LcA-W.js} +1 -1
  13. package/src/assets/web-panel/assets/{Audit-X1ce4Bjn.js → Audit-DK61tocB.js} +1 -1
  14. package/src/assets/web-panel/assets/{BackgroundAgents-BvWR-y5v.js → BackgroundAgents-BOKgDFRh.js} +1 -1
  15. package/src/assets/web-panel/assets/{Backup-BublSGEd.js → Backup-Bo9G4vTP.js} +1 -1
  16. package/src/assets/web-panel/assets/{BaseInput-CR_77Hso.js → BaseInput-Do_dE9_v.js} +1 -1
  17. package/src/assets/web-panel/assets/{Chat-DixW52tq.js → Chat-COVvZzOL.js} +6 -6
  18. package/src/assets/web-panel/assets/ChatBubbleRenderer-BUUyJ5QO.js +1 -0
  19. package/src/assets/web-panel/assets/{Checkbox-CCv1C87K.js → Checkbox-Z9MJMolt.js} +1 -1
  20. package/src/assets/web-panel/assets/{Codegen-B03Cc3dd.js → Codegen-BVzWKVbf.js} +1 -1
  21. package/src/assets/web-panel/assets/{Col-CA06-6pU.js → Col-C8MnYcbT.js} +1 -1
  22. package/src/assets/web-panel/assets/{Community-Bj881J52.js → Community-DQxqw15l.js} +1 -1
  23. package/src/assets/web-panel/assets/{Compact-UZLTPoaL.js → Compact-BAUmrh3y.js} +1 -1
  24. package/src/assets/web-panel/assets/{Compliance-kGwzRb_0.js → Compliance-jPAkQ3zK.js} +1 -1
  25. package/src/assets/web-panel/assets/{Cowork-KundoYM8.js → Cowork-BBQyxbh8.js} +2 -2
  26. package/src/assets/web-panel/assets/{Cron-BYZyoqDe.js → Cron-W5koq8Z5.js} +2 -2
  27. package/src/assets/web-panel/assets/{Crosschain-BhrrppBX.js → Crosschain-BQhnu5hS.js} +1 -1
  28. package/src/assets/web-panel/assets/{DID-CdC6EguZ.js → DID-DD7PlI18.js} +2 -2
  29. package/src/assets/web-panel/assets/{Dashboard-CcQx3J4y.js → Dashboard-w_ILuwKI.js} +2 -2
  30. package/src/assets/web-panel/assets/{Dropdown-BpijtEzQ.js → Dropdown-qiDUAyAo.js} +1 -1
  31. package/src/assets/web-panel/assets/{EmailListRenderer-DfZjeZTe.js → EmailListRenderer-PDeBcI5H.js} +1 -1
  32. package/src/assets/web-panel/assets/{FamilyGuardDashboard-BFYSf69C.js → FamilyGuardDashboard-SuArscRM.js} +1 -1
  33. package/src/assets/web-panel/assets/{Federation-Okdi3pJm.js → Federation-BCovZaUn.js} +1 -1
  34. package/src/assets/web-panel/assets/{FormItemContext-mH-HebKW.js → FormItemContext-DjlFdVFk.js} +1 -1
  35. package/src/assets/web-panel/assets/{GenericCardRenderer-xivV69x7.js → GenericCardRenderer-DoEZ7fcS.js} +1 -1
  36. package/src/assets/web-panel/assets/{Git-1rh_YBwg.js → Git-CV0-6GPI.js} +2 -2
  37. package/src/assets/web-panel/assets/{Governance-eAXkNxdY.js → Governance-B_SFN50w.js} +1 -1
  38. package/src/assets/web-panel/assets/{Inference-CS6dMVTN.js → Inference-heI0Eo6k.js} +1 -1
  39. package/src/assets/web-panel/assets/{KnowledgeGraph-Bq_TnkvD.js → KnowledgeGraph-Cz5INS5p.js} +1 -1
  40. package/src/assets/web-panel/assets/{Logs-KchHtiHt.js → Logs-BSFxiHkg.js} +2 -2
  41. package/src/assets/web-panel/assets/MarkdownRenderer-1mNozZ8v.js +1 -0
  42. package/src/assets/web-panel/assets/{Marketplace-CsmGcX5X.js → Marketplace-0TWTpE7k.js} +1 -1
  43. package/src/assets/web-panel/assets/{McpTools-BaPFnnyz.js → McpTools-CV4-wBLm.js} +4 -4
  44. package/src/assets/web-panel/assets/{Memory-2m-sUUf7.js → Memory-x5bH5rw5.js} +2 -2
  45. package/src/assets/web-panel/assets/{MobileBridge-D5nVZBRu.js → MobileBridge-DaBmUu9G.js} +1 -1
  46. package/src/assets/web-panel/assets/{MobileProjects-Bo8MLZvT.js → MobileProjects-DqnoFPgR.js} +1 -1
  47. package/src/assets/web-panel/assets/{Mtc-3GDvamGa.js → Mtc-B_zKEBif.js} +4 -4
  48. package/src/assets/web-panel/assets/{MtcAudit-D2zGuZCH.js → MtcAudit-BCfKD2yg.js} +4 -4
  49. package/src/assets/web-panel/assets/{Multisig-BBVQ4t6t.js → Multisig-DgYfj6c_.js} +3 -3
  50. package/src/assets/web-panel/assets/{NLProgramming-BIOCyOGp.js → NLProgramming-Dz09lFUm.js} +1 -1
  51. package/src/assets/web-panel/assets/{Notes-DVwBGpzc.js → Notes-Cuj6ugBE.js} +3 -3
  52. package/src/assets/web-panel/assets/{NotificationSettings-BDnHfIfX.js → NotificationSettings-DK2WXjhg.js} +1 -1
  53. package/src/assets/web-panel/assets/{OrderTableRenderer-C7Ek20ag.js → OrderTableRenderer-D1IAOQoo.js} +1 -1
  54. package/src/assets/web-panel/assets/{Organization-D3SRKaa7.js → Organization-9fqnPvTw.js} +4 -4
  55. package/src/assets/web-panel/assets/{Overflow-C2B8ahyn.js → Overflow-n5xHkhow.js} +1 -1
  56. package/src/assets/web-panel/assets/{P2P-2iwQzG65.js → P2P-B5AUWwH-.js} +2 -2
  57. package/src/assets/web-panel/assets/{PdhVaultBrowser-CoPiWqMN.js → PdhVaultBrowser-CoUDV6dy.js} +4 -4
  58. package/src/assets/web-panel/assets/{Permissions-yV5XNXo5.js → Permissions-DPRmQf_H.js} +4 -4
  59. package/src/assets/web-panel/assets/{PersonalDataHub-uCDJhMUF.js → PersonalDataHub-B37bsbEN.js} +4 -4
  60. package/src/assets/web-panel/assets/{Pipeline-hN7ucMOA.js → Pipeline-CCAuPqu9.js} +1 -1
  61. package/src/assets/web-panel/assets/{Privacy-CgU0ptN4.js → Privacy-B81msZdv.js} +1 -1
  62. package/src/assets/web-panel/assets/{ProjectInit-CfYPiT6j.js → ProjectInit-B-Py2NbF.js} +2 -2
  63. package/src/assets/web-panel/assets/{ProjectSettings-B0A2C4nw.js → ProjectSettings-CYsneiRB.js} +2 -2
  64. package/src/assets/web-panel/assets/{Projects-BE5C0ajn.js → Projects-Y975Mh6R.js} +1 -1
  65. package/src/assets/web-panel/assets/{Providers-CstQPf8W.js → Providers-uO-10aP1.js} +1 -1
  66. package/src/assets/web-panel/assets/{QrScannerModal-Co3z_ynk.js → QrScannerModal-Bj_6gJ0l.js} +1 -1
  67. package/src/assets/web-panel/assets/{QuickAsk-DMTS6pQ6.js → QuickAsk-BgpBCMPE.js} +1 -1
  68. package/src/assets/web-panel/assets/{Recommend-ETw4odUa.js → Recommend-DhUW3YUA.js} +1 -1
  69. package/src/assets/web-panel/assets/RemoteSession-DdeyfbkA.js +5 -0
  70. package/src/assets/web-panel/assets/{Reputation-BTVqQgKq.js → Reputation-fxW421xd.js} +1 -1
  71. package/src/assets/web-panel/assets/{Row-CBKKwwtO.js → Row-De73w1og.js} +1 -1
  72. package/src/assets/web-panel/assets/{RssFeed-BHUQ438I.js → RssFeed-3US_Q_lj.js} +2 -2
  73. package/src/assets/web-panel/assets/{Search-B4NqeUsF.js → Search-DbZ_DU74.js} +1 -1
  74. package/src/assets/web-panel/assets/{Security-BRjoYG5_.js → Security-DoIU1vMF.js} +4 -4
  75. package/src/assets/web-panel/assets/{Services-CxQzu3p3.js → Services-DB_RGqpc.js} +2 -2
  76. package/src/assets/web-panel/assets/{Skeleton-BKzwwmWO.js → Skeleton-DnzLaayC.js} +1 -1
  77. package/src/assets/web-panel/assets/{Skills-DrsFcgth.js → Skills-VzVXHENF.js} +1 -1
  78. package/src/assets/web-panel/assets/{Sla-D21tmXCH.js → Sla-DMfvjCAI.js} +1 -1
  79. package/src/assets/web-panel/assets/{SpeechSettings-CjyjLfp8.js → SpeechSettings-DQQOYWbF.js} +1 -1
  80. package/src/assets/web-panel/assets/{SyncSettings-Cu45-tnx.js → SyncSettings-Bmx3FG1P.js} +2 -2
  81. package/src/assets/web-panel/assets/{Tasks-oMcILYOj.js → Tasks-DOQyfb-2.js} +1 -1
  82. package/src/assets/web-panel/assets/{Templates-Bh-Zcr5R.js → Templates-D6hTRT7c.js} +1 -1
  83. package/src/assets/web-panel/assets/{Tenant-68ns63aI.js → Tenant-CTPQZAzs.js} +1 -1
  84. package/src/assets/web-panel/assets/{Terminal-BLJkFykE.js → Terminal-B7vII3oH.js} +2 -2
  85. package/src/assets/web-panel/assets/{TimelineRenderer-xcrJLq-O.js → TimelineRenderer-BkiFJeXV.js} +1 -1
  86. package/src/assets/web-panel/assets/{Tokens-C6PpFJtf.js → Tokens-BPPdQMNo.js} +1 -1
  87. package/src/assets/web-panel/assets/{Trigger-BaKA7tdM.js → Trigger-BxG8JAEI.js} +1 -1
  88. package/src/assets/web-panel/assets/{Trust-BzhA57Jc.js → Trust-DHD7nMpM.js} +1 -1
  89. package/src/assets/web-panel/assets/{UkeySign-4_U3JkIR.js → UkeySign-B4BJpU47.js} +1 -1
  90. package/src/assets/web-panel/assets/{VideoEditing-162AANxX.js → VideoEditing-CRiGpFx2.js} +1 -1
  91. package/src/assets/web-panel/assets/{Wallet-B1viuql_.js → Wallet-BSDSNiem.js} +4 -4
  92. package/src/assets/web-panel/assets/{WebAuthn-ZVWazhCy.js → WebAuthn-DLTUh5Zv.js} +5 -5
  93. package/src/assets/web-panel/assets/WorkflowEditor-tW2MwjhD.js +1 -0
  94. package/src/assets/web-panel/assets/{chat-DmrHNuwq.js → chat-SgsLQ3FC.js} +1 -1
  95. package/src/assets/web-panel/assets/{colors-DAJ_M-PK.js → colors-BHjjLIKO.js} +1 -1
  96. package/src/assets/web-panel/assets/{compact-item-uIzmYwLs.js → compact-item-VceErdKq.js} +1 -1
  97. package/src/assets/web-panel/assets/{createContext-D_ZmuLwb.js → createContext-BZ332MYl.js} +1 -1
  98. package/src/assets/web-panel/assets/devWarning-DosMaL-k.js +1 -0
  99. package/src/assets/web-panel/assets/{hasIn-CDt_shTL.js → hasIn-CBTjJOjn.js} +1 -1
  100. package/src/assets/web-panel/assets/index-0O5ePnUq.js +1 -0
  101. package/src/assets/web-panel/assets/{index-rXdwUeE3.js → index-1n7xjKDU.js} +1 -1
  102. package/src/assets/web-panel/assets/{index-BAvSmJXW.js → index-29wVzi-k.js} +1 -1
  103. package/src/assets/web-panel/assets/{index-C16fYKFD.js → index-B2u26AS3.js} +1 -1
  104. package/src/assets/web-panel/assets/{index-C5WcdJH2.js → index-B40dQn0G.js} +3 -3
  105. package/src/assets/web-panel/assets/{index-CaelyMgF.js → index-B5KAGxNo.js} +1 -1
  106. package/src/assets/web-panel/assets/{index-OzTxFIJP.js → index-BC1xuQgX.js} +1 -1
  107. package/src/assets/web-panel/assets/{index-CzXqvMuP.js → index-BETEjuNr.js} +1 -1
  108. package/src/assets/web-panel/assets/{index-clSX4PEc.js → index-BOtvPdHt.js} +1 -1
  109. package/src/assets/web-panel/assets/{index-saf3VNeP.js → index-BPXFLYdW.js} +1 -1
  110. package/src/assets/web-panel/assets/{index-DdUMcwRB.js → index-BQSmZGgs.js} +1 -1
  111. package/src/assets/web-panel/assets/{index-DfSSy9uy.js → index-BRWhLbWF.js} +1 -1
  112. package/src/assets/web-panel/assets/{index-GcKOHD_c.js → index-BTlc-Bve.js} +1 -1
  113. package/src/assets/web-panel/assets/{index-BHvlm0In.js → index-BY1wexYG.js} +1 -1
  114. package/src/assets/web-panel/assets/{index-Cvak6Oe2.js → index-BcUenmCX.js} +1 -1
  115. package/src/assets/web-panel/assets/{index-CKyd472S.js → index-BeiqkxJG.js} +1 -1
  116. package/src/assets/web-panel/assets/{index-DvFhhe6r.js → index-BhXZEUAc.js} +1 -1
  117. package/src/assets/web-panel/assets/{index-DBFmcaCQ.js → index-By5jBtkV.js} +1 -1
  118. package/src/assets/web-panel/assets/{index-DqDSF2RW.js → index-CLc19alH.js} +1 -1
  119. package/src/assets/web-panel/assets/{index-DeGBoLQA.js → index-CMVvxIS8.js} +1 -1
  120. package/src/assets/web-panel/assets/{index-FyplHuNt.js → index-C_B9rFQE.js} +1 -1
  121. package/src/assets/web-panel/assets/{index-CeUpKjvi.js → index-CaIS_TNX.js} +1 -1
  122. package/src/assets/web-panel/assets/{index-BRa4kJ_S.js → index-CawpdGTW.js} +1 -1
  123. package/src/assets/web-panel/assets/index-ChAzPigF.js +1 -0
  124. package/src/assets/web-panel/assets/{index-QsgfSZrV.js → index-CpumS98o.js} +1 -1
  125. package/src/assets/web-panel/assets/{index-BDF6EQBs.js → index-Cr4rjlW5.js} +1 -1
  126. package/src/assets/web-panel/assets/{index-rS3gl_68.js → index-CrUgDQkf.js} +1 -1
  127. package/src/assets/web-panel/assets/{index-D4n1lGTB.js → index-CtjLcCNU.js} +1 -1
  128. package/src/assets/web-panel/assets/{index-ifxmu8SY.js → index-DUkVN794.js} +1 -1
  129. package/src/assets/web-panel/assets/{index-BTCXf1t-.js → index-D_SePDWj.js} +1 -1
  130. package/src/assets/web-panel/assets/{index-Bbxjfstq.js → index-DaCCy6lq.js} +1 -1
  131. package/src/assets/web-panel/assets/{index-D9OE8B3d.js → index-DdefYBSw.js} +1 -1
  132. package/src/assets/web-panel/assets/{index-CG0lY4h3.js → index-DePOeMDM.js} +1 -1
  133. package/src/assets/web-panel/assets/{index-Bx4d6c1k.js → index-DgPvuIn2.js} +1 -1
  134. package/src/assets/web-panel/assets/{index-Z8P4BexE.js → index-FJ3zC9LK.js} +1 -1
  135. package/src/assets/web-panel/assets/{index-ByzGvpR9.js → index-_PHrIO4O.js} +1 -1
  136. package/src/assets/web-panel/assets/{index-DwfvPe-j.js → index-fU7wNHDB.js} +1 -1
  137. package/src/assets/web-panel/assets/{index-CcZ9xJI9.js → index-iYQNU9za.js} +1 -1
  138. package/src/assets/web-panel/assets/{index-CbD_WW_6.js → index-vIXOpcjD.js} +1 -1
  139. package/src/assets/web-panel/assets/{initDefaultProps-pmfaD-aK.js → initDefaultProps-D1oZNz0D.js} +1 -1
  140. package/src/assets/web-panel/assets/{motion-CaQ2CpYF.js → motion-C8k6doMO.js} +1 -1
  141. package/src/assets/web-panel/assets/{move-CkKTqNbI.js → move-DJ4qSbYt.js} +1 -1
  142. package/src/assets/web-panel/assets/{mtc-parser-Dd4xsgRI.js → mtc-parser-CdpJD85j.js} +1 -1
  143. package/src/assets/web-panel/assets/{omit-BFuA5YEs.js → omit-B0V4slKA.js} +1 -1
  144. package/src/assets/web-panel/assets/{pickAttrs-DDfxOAJc.js → pickAttrs-Di5NiW1F.js} +1 -1
  145. package/src/assets/web-panel/assets/{placementArrow-Be595JCp.js → placementArrow-B5OMHgEM.js} +1 -1
  146. package/src/assets/web-panel/assets/{responsiveObserve-7byljUM3.js → responsiveObserve-C6U9IWql.js} +1 -1
  147. package/src/assets/web-panel/assets/{slide-CTWfOTaY.js → slide-BMyTH3rN.js} +1 -1
  148. package/src/assets/web-panel/assets/{statusUtils-CNZOuIxM.js → statusUtils-C7d6BG7Z.js} +1 -1
  149. package/src/assets/web-panel/assets/{styleChecker-DwOcBV78.js → styleChecker-CR50QnQy.js} +1 -1
  150. package/src/assets/web-panel/assets/{useFlexGapSupport-B6MNwAO9.js → useFlexGapSupport-DBEeoMb8.js} +1 -1
  151. package/src/assets/web-panel/assets/{useFs-CzyQjH9r.js → useFs-3r099cjv.js} +1 -1
  152. package/src/assets/web-panel/assets/{usePersonalDataHub-Dxh3KiVA.js → usePersonalDataHub-C5IYNdTK.js} +1 -1
  153. package/src/assets/web-panel/assets/{vnode-DVfZiS5N.js → vnode-DmEOLRHf.js} +1 -1
  154. package/src/assets/web-panel/assets/{zoom-BYme7fbY.js → zoom-DnpUiVCB.js} +1 -1
  155. package/src/assets/web-panel/index.html +1 -1
  156. package/src/cli-help.js +181 -0
  157. package/src/command-help-index.json +181 -0
  158. package/src/command-manifest.json +1271 -175
  159. package/src/command-surface-policy.js +312 -0
  160. package/src/commands/agent.js +76 -33
  161. package/src/commands/artifacts.js +400 -2
  162. package/src/commands/background-session.js +41 -3
  163. package/src/commands/checkpoint-restore-recovery.js +1476 -0
  164. package/src/commands/checkpoint.js +947 -22
  165. package/src/commands/compact.js +101 -25
  166. package/src/commands/config.js +506 -20
  167. package/src/commands/doctor.js +4 -1
  168. package/src/commands/hub.js +1 -1
  169. package/src/commands/mcp.js +688 -24
  170. package/src/commands/pack.js +16 -2
  171. package/src/commands/plugin.js +20 -12
  172. package/src/commands/session-mcp-recovery.js +218 -0
  173. package/src/commands/session.js +470 -110
  174. package/src/commands/setup.js +36 -10
  175. package/src/commands/skill.js +181 -110
  176. package/src/commands/status.js +21 -5
  177. package/src/commands/update.js +68 -6
  178. package/src/constants.js +11 -0
  179. package/src/data/changelog.json +14 -1
  180. package/src/gateways/ws/action-protocol.js +10 -0
  181. package/src/gateways/ws/session-protocol.js +555 -27
  182. package/src/gateways/ws/ws-agent-handler.js +541 -14
  183. package/src/gateways/ws/ws-session-gateway.js +129 -22
  184. package/src/gateways/ws/ws-session-state.js +431 -0
  185. package/src/harness/background-task-manager.js +233 -36
  186. package/src/harness/jsonl-session-store.js +2551 -167
  187. package/src/harness/mcp-client.js +802 -61
  188. package/src/harness/prompt-compressor.js +227 -27
  189. package/src/harness/session-index.js +89 -38
  190. package/src/harness/session-list-index.js +254 -0
  191. package/src/harness/structured-handoff.js +410 -0
  192. package/src/lazy-dispatch.js +747 -84
  193. package/src/lib/__tests__/logger.test.js +50 -2
  194. package/src/lib/advisor-runtime.js +1047 -0
  195. package/src/lib/agent-core.js +1 -0
  196. package/src/lib/agent-sandbox.js +120 -26
  197. package/src/lib/agent-session-export.js +20 -0
  198. package/src/lib/agent-team/team-runner.js +230 -8
  199. package/src/lib/artifact-store.js +131 -8
  200. package/src/lib/background-agent-supervisor.js +185 -13
  201. package/src/lib/background-interaction-journal.js +37 -9
  202. package/src/lib/background-launch-profile.js +1058 -0
  203. package/src/lib/checkpoint-restore-already-completed-controller.js +902 -0
  204. package/src/lib/checkpoint-restore-orchestrator.js +987 -0
  205. package/src/lib/checkpoint-restore-partial-rollback-controller.js +1953 -0
  206. package/src/lib/checkpoint-restore-recovery-controller.js +689 -0
  207. package/src/lib/checkpoint-restore-recovery.js +884 -0
  208. package/src/lib/checkpoint-restore-retention.js +126 -0
  209. package/src/lib/checkpoint-restore-saga.js +5079 -0
  210. package/src/lib/checkpoint-restore-session-recovery.js +1178 -0
  211. package/src/lib/checkpoint-restore-workspace-target-verifier.js +270 -0
  212. package/src/lib/checkpoint-store.js +1688 -97
  213. package/src/lib/checkpoint-timeline-authority.js +522 -0
  214. package/src/lib/checkpoint-timeline.js +642 -0
  215. package/src/lib/config-keys.js +18 -119
  216. package/src/lib/config-manager.js +446 -27
  217. package/src/lib/config-redaction.js +57 -0
  218. package/src/lib/config-schema.js +853 -0
  219. package/src/lib/cowork-mcp-tools.js +5 -0
  220. package/src/lib/cowork-task-runner.js +458 -63
  221. package/src/lib/delivery-coordinator.js +1234 -0
  222. package/src/lib/delivery-evidence.js +450 -0
  223. package/src/lib/doctor-checkup.js +199 -14
  224. package/src/lib/executable-path.js +34 -0
  225. package/src/lib/fatal-handler.js +16 -0
  226. package/src/lib/file-checkpoint.js +4909 -186
  227. package/src/lib/file-lines.js +125 -0
  228. package/src/lib/headless-config-command.js +16 -5
  229. package/src/lib/hook-runner.cjs +139 -38
  230. package/src/lib/hooks-v2-workspace-context.js +10 -0
  231. package/src/lib/impacted-gate-selector.js +424 -0
  232. package/src/lib/interaction-adapter.js +62 -0
  233. package/src/lib/jsonl-session-store.js +1 -35
  234. package/src/lib/logger.js +66 -14
  235. package/src/lib/mcp-call-ledger-store.js +1069 -0
  236. package/src/lib/mcp-call-ledger.js +747 -0
  237. package/src/lib/mcp-client.js +5 -0
  238. package/src/lib/mcp-conflict-scheduler.js +468 -0
  239. package/src/lib/mcp-effect-contract.js +102 -0
  240. package/src/lib/mcp-headers-helper-trust.js +145 -0
  241. package/src/lib/mcp-headers-helper.js +616 -0
  242. package/src/lib/mcp-host-recovery-runtime.js +131 -0
  243. package/src/lib/mcp-ledger-recovery-admission.js +1065 -0
  244. package/src/lib/mcp-recovery-adjudication.js +443 -0
  245. package/src/lib/output-context.js +86 -0
  246. package/src/lib/packer/file-identity.js +170 -0
  247. package/src/lib/packer/index.js +3 -0
  248. package/src/lib/packer/native-update-state.js +277 -0
  249. package/src/lib/packer/pack-update-applier.js +1983 -136
  250. package/src/lib/packer/pack-update-checker.js +34 -0
  251. package/src/lib/packer/pack-update-downloader.js +875 -86
  252. package/src/lib/packer/pack-update-signature.js +86 -0
  253. package/src/lib/packer/pkg-config-generator.js +6 -0
  254. package/src/lib/paths.js +65 -10
  255. package/src/lib/plan-mode.js +831 -14
  256. package/src/lib/plan-persistence.js +540 -0
  257. package/src/lib/plugin-runtime/hooks.js +227 -69
  258. package/src/lib/plugin-runtime/plugin-options.js +189 -44
  259. package/src/lib/pr-automation-policy.js +146 -8
  260. package/src/lib/process-execution-broker/credential-agent.js +7 -0
  261. package/src/lib/process-execution-broker/index.js +53 -8
  262. package/src/lib/process-execution-broker/workspace-transaction.js +976 -73
  263. package/src/lib/project-instructions.js +165 -29
  264. package/src/lib/project-mcp-trust.js +67 -0
  265. package/src/lib/public-ide-capability-manifest.js +278 -0
  266. package/src/lib/public-ide-capability-validation.js +351 -0
  267. package/src/lib/secret-store.js +267 -43
  268. package/src/lib/secure-fs.js +1089 -0
  269. package/src/lib/security-audit.js +58 -0
  270. package/src/lib/service-manager.js +34 -13
  271. package/src/lib/session-budget-runtime.js +1452 -0
  272. package/src/lib/session-host-snapshot.js +388 -0
  273. package/src/lib/session-message-provenance.js +429 -0
  274. package/src/lib/session-projection.js +804 -0
  275. package/src/lib/session-resource-budget.js +1495 -0
  276. package/src/lib/settings-hooks.cjs +138 -22
  277. package/src/lib/settings-loader.cjs +25 -2
  278. package/src/lib/side-effect-ledger-store.js +45 -23
  279. package/src/lib/skill-execution-identity.js +217 -0
  280. package/src/lib/skill-loader.js +299 -18
  281. package/src/lib/sub-agent-context.js +236 -28
  282. package/src/lib/sub-agent-registry.js +5 -0
  283. package/src/lib/subagent-contract.js +94 -0
  284. package/src/lib/todo-manager.js +185 -18
  285. package/src/lib/todo-persistence.js +417 -0
  286. package/src/lib/turn-binding-store.js +41 -23
  287. package/src/lib/with-file-lock.js +25 -1
  288. package/src/lib/ws-chat-handler.js +21 -0
  289. package/src/program-base.js +18 -0
  290. package/src/repl/advisor-command.js +103 -0
  291. package/src/repl/agent-repl.js +2332 -154
  292. package/src/repl/btw-command.js +93 -10
  293. package/src/repl/clipboard-image.js +119 -0
  294. package/src/repl/config-summary.js +64 -108
  295. package/src/repl/prompt-editor.js +233 -0
  296. package/src/repl/prompt-interactions.js +404 -0
  297. package/src/repl/prompt-stash.js +354 -0
  298. package/src/repl/prompt-suggestions.js +312 -0
  299. package/src/repl/repl-keybindings.js +227 -0
  300. package/src/repl/session-recap.js +217 -0
  301. package/src/repl/slash-command-registry.js +25 -4
  302. package/src/repl/terminal-layout.js +88 -0
  303. package/src/runtime/agent-core.js +1264 -325
  304. package/src/runtime/coding-agent-contract-shared.cjs +13 -1
  305. package/src/runtime/coding-agent-managed-tool-policy.cjs +7 -2
  306. package/src/runtime/diagnostics.js +32 -13
  307. package/src/runtime/headless-runner.js +298 -118
  308. package/src/runtime/headless-stream.js +353 -95
  309. package/src/runtime/mcp-config.js +148 -14
  310. package/src/runtime/status-diagnostics-lite.js +137 -0
  311. package/src/assets/web-panel/assets/ChatBubbleRenderer-DDHDpBBA.js +0 -1
  312. package/src/assets/web-panel/assets/MarkdownRenderer-TabqHW9B.js +0 -1
  313. package/src/assets/web-panel/assets/RemoteSession-B0rkE09d.js +0 -5
  314. package/src/assets/web-panel/assets/WorkflowEditor-tzdyUk-1.js +0 -1
  315. package/src/assets/web-panel/assets/devWarning-DIrncrV1.js +0 -1
  316. package/src/assets/web-panel/assets/index-D-Swwqld.js +0 -1
  317. package/src/assets/web-panel/assets/index-FMZAMKy8.js +0 -1
@@ -3,28 +3,481 @@
3
3
  */
4
4
 
5
5
  import {
6
+ constants as fsConstants,
6
7
  existsSync,
7
- mkdirSync,
8
8
  appendFileSync,
9
9
  readFileSync,
10
10
  readdirSync,
11
11
  copyFileSync,
12
+ renameSync,
12
13
  rmSync,
14
+ openSync,
15
+ closeSync,
16
+ fstatSync,
17
+ lstatSync,
18
+ readSync,
19
+ ftruncateSync,
13
20
  } from "node:fs";
14
21
  import { join, basename, resolve } from "node:path";
15
- import { createHash } from "node:crypto";
22
+ import { createHash, randomUUID } from "node:crypto";
23
+ import { TextDecoder } from "node:util";
16
24
  import { getHomeDir } from "../lib/paths.js";
25
+ import { ensurePrivateDirectory, ensurePrivateFile } from "../lib/secure-fs.js";
17
26
  import {
18
27
  computeEventHash,
19
- latestChainHash,
20
- verifyTranscriptText,
21
28
  TRANSCRIPT_CHAIN_STATUS,
22
29
  } from "./transcript-integrity.js";
23
30
  import { withFileLock } from "../lib/with-file-lock.js";
31
+ import {
32
+ decodeVerifiedPersistedMessage,
33
+ DURABLE_SYSTEM_MESSAGE_KINDS,
34
+ encodePersistedMessage,
35
+ markDurableSystemMessage,
36
+ projectCanonicalResumeMessages,
37
+ sanitizePersistedMessage,
38
+ SESSION_FORK_AUTHORITY_FIELD,
39
+ } from "../lib/session-message-provenance.js";
40
+ import {
41
+ iterateFileLinesSync,
42
+ iterateFileLinesReverseSync,
43
+ } from "../lib/file-lines.js";
44
+ import {
45
+ emptySessionMeta,
46
+ applyEventToSessionMeta,
47
+ listIndexedSessions,
48
+ publicSessionMeta,
49
+ readLatestSessionActivity,
50
+ readSessionMeta,
51
+ recordSessionDeleted,
52
+ recordSessionEvent,
53
+ recordSessionActivity,
54
+ replaceSessionMeta,
55
+ } from "./session-list-index.js";
56
+
57
+ let securedSessionsDir = null;
58
+ let securedSessionsDirIdentity = null;
59
+
60
+ // Deterministic process-crash injection for the independent session-scale
61
+ // gate. The hooks are inert unless the dedicated child process opts in; this
62
+ // keeps production behavior and ordinary tests on the exact same append path.
63
+ export const _sessionScaleFaultHooks = Object.seal({
64
+ afterTranscriptAppend: null,
65
+ afterForkCopy: null,
66
+ afterForkLineage: null,
67
+ afterForkPublish: null,
68
+ afterForkMeta: null,
69
+ });
70
+
71
+ export const WS_TURN_EVENT = "ws_turn";
72
+ export const WS_TURN_CLAIM_EVENT = "ws_turn_claim";
73
+ export const WS_TURN_SCHEMA_VERSION = 1;
74
+ export const WS_TURN_REQUEST_ID_MAX_BYTES = 128;
75
+ const WS_TURN_CONTENT_MAX_BYTES = 4 * 1024 * 1024;
76
+ const WS_TURN_REQUEST_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:@-]*$/;
77
+ const WS_TURN_INPUT_DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/;
78
+ const WS_TURN_CLAIM_ID_PATTERN = /^claim-[0-9a-f-]{36}$/;
79
+ const WS_TURN_FAILURE_CODE_PATTERN = /^[A-Z][A-Z0-9_]{0,63}$/;
80
+
81
+ function wsTurnError(message, code) {
82
+ const error = new Error(message);
83
+ error.code = code;
84
+ return error;
85
+ }
86
+
87
+ function hasExactKeys(value, expectedKeys) {
88
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
89
+ const actual = Object.keys(value).sort();
90
+ const expected = [...expectedKeys].sort();
91
+ return (
92
+ actual.length === expected.length &&
93
+ actual.every((key, index) => key === expected[index])
94
+ );
95
+ }
96
+
97
+ export function normalizeWsTurnRequestId(value) {
98
+ if (typeof value !== "string") {
99
+ throw wsTurnError(
100
+ "Canonical WebSocket turns require a string request id",
101
+ "CC_WS_REQUEST_ID_REQUIRED",
102
+ );
103
+ }
104
+ const canonical = value.normalize("NFC");
105
+ if (
106
+ canonical !== value ||
107
+ !WS_TURN_REQUEST_ID_PATTERN.test(canonical) ||
108
+ Buffer.byteLength(canonical, "utf8") > WS_TURN_REQUEST_ID_MAX_BYTES
109
+ ) {
110
+ throw wsTurnError(
111
+ "WebSocket request id is not bounded canonical ASCII",
112
+ "CC_WS_REQUEST_ID_INVALID",
113
+ );
114
+ }
115
+ return canonical;
116
+ }
117
+
118
+ function normalizeWsTurnContent(value, role) {
119
+ if (
120
+ typeof value !== "string" ||
121
+ (role === "assistant" && value.trim().length === 0)
122
+ ) {
123
+ throw wsTurnError(
124
+ `Canonical WebSocket ${role} content is invalid`,
125
+ role === "assistant"
126
+ ? "CC_WS_EMPTY_ASSISTANT_RESPONSE"
127
+ : "CC_WS_USER_MESSAGE_INVALID",
128
+ );
129
+ }
130
+ if (Buffer.byteLength(value, "utf8") > WS_TURN_CONTENT_MAX_BYTES) {
131
+ throw wsTurnError(
132
+ `Canonical WebSocket ${role} content exceeds the durable event limit`,
133
+ "CC_WS_TURN_CONTENT_TOO_LARGE",
134
+ );
135
+ }
136
+ return value;
137
+ }
138
+
139
+ function normalizeWsTurnInputDigest(value) {
140
+ if (typeof value !== "string" || !WS_TURN_INPUT_DIGEST_PATTERN.test(value)) {
141
+ throw wsTurnError(
142
+ "Canonical WebSocket turn input digest is invalid",
143
+ "CC_WS_TURN_INPUT_DIGEST_INVALID",
144
+ );
145
+ }
146
+ return value;
147
+ }
148
+
149
+ function normalizeWsTurnClaimId(value) {
150
+ if (typeof value !== "string" || !WS_TURN_CLAIM_ID_PATTERN.test(value)) {
151
+ throw wsTurnError(
152
+ "Canonical WebSocket turn claim id is invalid",
153
+ "CC_WS_TURN_CLAIM_ID_INVALID",
154
+ );
155
+ }
156
+ return value;
157
+ }
158
+
159
+ function normalizeWsTurnFailureCode(value) {
160
+ if (typeof value !== "string" || !WS_TURN_FAILURE_CODE_PATTERN.test(value)) {
161
+ throw wsTurnError(
162
+ "Canonical WebSocket turn failure code is invalid",
163
+ "CC_WS_TURN_FAILURE_CODE_INVALID",
164
+ );
165
+ }
166
+ return value;
167
+ }
168
+
169
+ export function createWsTurnClaimId() {
170
+ return `claim-${randomUUID()}`;
171
+ }
172
+
173
+ export function computeWsTurnInputDigest(user) {
174
+ const content = normalizeWsTurnContent(user, "user");
175
+ return `sha256:${createHash("sha256").update(content).digest("hex")}`;
176
+ }
177
+
178
+ function canonicalWsTurnClaimData({ requestId, inputDigest, opaqueClaimId }) {
179
+ return Object.freeze({
180
+ schemaVersion: WS_TURN_SCHEMA_VERSION,
181
+ requestId: normalizeWsTurnRequestId(requestId),
182
+ inputDigest: normalizeWsTurnInputDigest(inputDigest),
183
+ opaqueClaimId: normalizeWsTurnClaimId(opaqueClaimId),
184
+ });
185
+ }
186
+
187
+ function canonicalWsTurnCompletedData({
188
+ requestId,
189
+ inputDigest = null,
190
+ opaqueClaimId = null,
191
+ user,
192
+ assistant,
193
+ }) {
194
+ const normalizedUser = normalizeWsTurnContent(user, "user");
195
+ const hasClaimAuthority = inputDigest != null || opaqueClaimId != null;
196
+ const data = {
197
+ schemaVersion: WS_TURN_SCHEMA_VERSION,
198
+ requestId: normalizeWsTurnRequestId(requestId),
199
+ outcome: "completed",
200
+ user: Object.freeze({
201
+ role: "user",
202
+ content: normalizedUser,
203
+ }),
204
+ assistant: Object.freeze({
205
+ role: "assistant",
206
+ content: normalizeWsTurnContent(assistant, "assistant"),
207
+ }),
208
+ };
209
+ if (hasClaimAuthority) {
210
+ data.inputDigest = normalizeWsTurnInputDigest(inputDigest);
211
+ data.opaqueClaimId = normalizeWsTurnClaimId(opaqueClaimId);
212
+ if (data.inputDigest !== computeWsTurnInputDigest(normalizedUser)) {
213
+ throw wsTurnError(
214
+ "WebSocket turn settlement input does not match its durable claim",
215
+ "CC_WS_TURN_INPUT_DIGEST_MISMATCH",
216
+ );
217
+ }
218
+ }
219
+ return Object.freeze(data);
220
+ }
221
+
222
+ function canonicalWsTurnFailedData({
223
+ requestId,
224
+ inputDigest,
225
+ opaqueClaimId,
226
+ failureCode,
227
+ }) {
228
+ return Object.freeze({
229
+ schemaVersion: WS_TURN_SCHEMA_VERSION,
230
+ requestId: normalizeWsTurnRequestId(requestId),
231
+ inputDigest: normalizeWsTurnInputDigest(inputDigest),
232
+ opaqueClaimId: normalizeWsTurnClaimId(opaqueClaimId),
233
+ outcome: "failed",
234
+ failure: Object.freeze({ code: normalizeWsTurnFailureCode(failureCode) }),
235
+ });
236
+ }
237
+
238
+ export function projectWsTurnClaim(event) {
239
+ if (event?.type !== WS_TURN_CLAIM_EVENT) return null;
240
+ try {
241
+ if (
242
+ !hasExactKeys(event.data, [
243
+ "schemaVersion",
244
+ "requestId",
245
+ "inputDigest",
246
+ "opaqueClaimId",
247
+ ])
248
+ ) {
249
+ return null;
250
+ }
251
+ const data = canonicalWsTurnClaimData(event.data || {});
252
+ if (event.data?.schemaVersion !== WS_TURN_SCHEMA_VERSION) return null;
253
+ return Object.freeze({
254
+ ...data,
255
+ eventHash: typeof event.hash === "string" ? event.hash : null,
256
+ });
257
+ } catch {
258
+ return null;
259
+ }
260
+ }
261
+
262
+ export function projectWsTurnSettlement(event) {
263
+ if (event?.type !== WS_TURN_EVENT) return null;
264
+ try {
265
+ if (event.data?.outcome === "failed") {
266
+ if (
267
+ !hasExactKeys(event.data, [
268
+ "schemaVersion",
269
+ "requestId",
270
+ "inputDigest",
271
+ "opaqueClaimId",
272
+ "outcome",
273
+ "failure",
274
+ ]) ||
275
+ !hasExactKeys(event.data?.failure, ["code"])
276
+ ) {
277
+ return null;
278
+ }
279
+ const data = canonicalWsTurnFailedData({
280
+ requestId: event.data?.requestId,
281
+ inputDigest: event.data?.inputDigest,
282
+ opaqueClaimId: event.data?.opaqueClaimId,
283
+ failureCode: event.data?.failure?.code,
284
+ });
285
+ if (event.data?.schemaVersion !== WS_TURN_SCHEMA_VERSION) return null;
286
+ return Object.freeze({
287
+ ...data,
288
+ eventHash: typeof event.hash === "string" ? event.hash : null,
289
+ });
290
+ }
291
+ const claimedSettlement =
292
+ event.data?.inputDigest != null || event.data?.opaqueClaimId != null;
293
+ if (
294
+ !hasExactKeys(
295
+ event.data,
296
+ claimedSettlement
297
+ ? [
298
+ "schemaVersion",
299
+ "requestId",
300
+ "inputDigest",
301
+ "opaqueClaimId",
302
+ "outcome",
303
+ "user",
304
+ "assistant",
305
+ ]
306
+ : ["schemaVersion", "requestId", "outcome", "user", "assistant"],
307
+ ) ||
308
+ !hasExactKeys(event.data?.user, ["role", "content"]) ||
309
+ !hasExactKeys(event.data?.assistant, ["role", "content"])
310
+ ) {
311
+ return null;
312
+ }
313
+ const data = canonicalWsTurnCompletedData({
314
+ requestId: event.data?.requestId,
315
+ inputDigest: event.data?.inputDigest,
316
+ opaqueClaimId: event.data?.opaqueClaimId,
317
+ user: event.data?.user?.content,
318
+ assistant: event.data?.assistant?.content,
319
+ });
320
+ if (
321
+ event.data?.schemaVersion !== WS_TURN_SCHEMA_VERSION ||
322
+ event.data?.outcome !== "completed" ||
323
+ event.data?.user?.role !== "user" ||
324
+ event.data?.assistant?.role !== "assistant"
325
+ ) {
326
+ return null;
327
+ }
328
+ return Object.freeze({
329
+ ...data,
330
+ eventHash: typeof event.hash === "string" ? event.hash : null,
331
+ });
332
+ } catch {
333
+ return null;
334
+ }
335
+ }
336
+
337
+ export function projectWsTurnMessages(event) {
338
+ const settlement = projectWsTurnSettlement(event);
339
+ return settlement?.outcome === "completed" ? settlement : null;
340
+ }
341
+
342
+ function wsTurnAuthorityError(message, code = "CC_WS_TURN_AUTHORITY_INVALID") {
343
+ return wsTurnError(message, code);
344
+ }
345
+
346
+ export function createWsTurnLifecycleReducer({
347
+ requestId,
348
+ inputDigest = null,
349
+ } = {}) {
350
+ const canonicalRequestId = normalizeWsTurnRequestId(requestId);
351
+ const expectedInputDigest =
352
+ inputDigest == null ? null : normalizeWsTurnInputDigest(inputDigest);
353
+ let claim = null;
354
+ let settlement = null;
355
+
356
+ return {
357
+ accept(event) {
358
+ if (
359
+ event?.type !== WS_TURN_CLAIM_EVENT &&
360
+ event?.type !== WS_TURN_EVENT
361
+ ) {
362
+ return;
363
+ }
364
+ if (event?.data?.requestId !== canonicalRequestId) return;
365
+
366
+ if (event.type === WS_TURN_CLAIM_EVENT) {
367
+ const projected = projectWsTurnClaim(event);
368
+ if (!projected?.eventHash) {
369
+ throw wsTurnAuthorityError(
370
+ "WebSocket request claim is malformed or unchained",
371
+ );
372
+ }
373
+ if (claim || settlement) {
374
+ throw wsTurnAuthorityError(
375
+ "WebSocket request has duplicate or out-of-order claim authority",
376
+ "CC_WS_TURN_CLAIM_DUPLICATE",
377
+ );
378
+ }
379
+ claim = projected;
380
+ return;
381
+ }
382
+
383
+ const projected = projectWsTurnSettlement(event);
384
+ if (!projected?.eventHash) {
385
+ throw wsTurnAuthorityError(
386
+ "WebSocket request settlement is malformed or unchained",
387
+ );
388
+ }
389
+ if (settlement) {
390
+ throw wsTurnAuthorityError(
391
+ "WebSocket request has multiple durable settlements",
392
+ "CC_WS_TURN_IDEMPOTENCY_DUPLICATE",
393
+ );
394
+ }
395
+ const claimedSettlement = projected.opaqueClaimId != null;
396
+ if (claimedSettlement) {
397
+ if (
398
+ !claim ||
399
+ claim.opaqueClaimId !== projected.opaqueClaimId ||
400
+ claim.inputDigest !== projected.inputDigest
401
+ ) {
402
+ throw wsTurnAuthorityError(
403
+ "WebSocket request settlement does not match its preceding claim",
404
+ "CC_WS_TURN_CLAIM_MISMATCH",
405
+ );
406
+ }
407
+ } else if (claim) {
408
+ throw wsTurnAuthorityError(
409
+ "Claimed WebSocket request used a legacy unfenced settlement",
410
+ "CC_WS_TURN_CLAIM_MISMATCH",
411
+ );
412
+ }
413
+ settlement = projected;
414
+ },
415
+ finish(authority = {}) {
416
+ const durableInputDigest =
417
+ claim?.inputDigest ||
418
+ settlement?.inputDigest ||
419
+ (settlement?.outcome === "completed"
420
+ ? computeWsTurnInputDigest(settlement.user.content)
421
+ : null);
422
+ if (
423
+ expectedInputDigest &&
424
+ durableInputDigest &&
425
+ expectedInputDigest !== durableInputDigest
426
+ ) {
427
+ throw wsTurnAuthorityError(
428
+ "WebSocket request id was already claimed with different input",
429
+ "CC_WS_REQUEST_ID_CONFLICT",
430
+ );
431
+ }
432
+ const status = settlement?.outcome || (claim ? "pending" : "none");
433
+ return Object.freeze({
434
+ requestId: canonicalRequestId,
435
+ inputDigest: durableInputDigest || expectedInputDigest,
436
+ status,
437
+ claim,
438
+ settlement,
439
+ turn: settlement?.outcome === "completed" ? settlement : null,
440
+ headHash:
441
+ typeof authority.headHash === "string" ? authority.headHash : null,
442
+ eventCount: Number.isSafeInteger(authority.eventCount)
443
+ ? authority.eventCount
444
+ : 0,
445
+ });
446
+ },
447
+ };
448
+ }
449
+
450
+ function runSessionScaleFaultHook(name, payload) {
451
+ if (process.env.CC_SESSION_SCALE_FAULT_INJECTION !== "1") return;
452
+ const hook = _sessionScaleFaultHooks[name];
453
+ if (typeof hook === "function") hook(payload);
454
+ }
24
455
 
25
456
  function getSessionsDir() {
26
457
  const dir = join(getHomeDir(), "sessions");
27
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
458
+ let current = null;
459
+ try {
460
+ current = lstatSync(dir);
461
+ } catch {
462
+ // The owner-only helper below creates a missing directory and surfaces any
463
+ // other unsafe filesystem state with its canonical error.
464
+ }
465
+ const currentIdentity = current ? `${current.dev}:${current.ino}` : null;
466
+ const unsafePosixMode =
467
+ process.platform !== "win32" &&
468
+ current !== null &&
469
+ (current.mode & 0o777) !== 0o700;
470
+ if (
471
+ securedSessionsDir !== dir ||
472
+ currentIdentity === null ||
473
+ currentIdentity !== securedSessionsDirIdentity ||
474
+ unsafePosixMode
475
+ ) {
476
+ ensurePrivateDirectory(dir);
477
+ securedSessionsDir = dir;
478
+ const secured = lstatSync(dir);
479
+ securedSessionsDirIdentity = `${secured.dev}:${secured.ino}`;
480
+ }
28
481
  return dir;
29
482
  }
30
483
 
@@ -61,42 +514,687 @@ export function appendTokenUsage(sessionId, usage) {
61
514
  appendEvent(sessionId, "token_usage", usage || {});
62
515
  }
63
516
 
64
- // Chain-tail cache: hash of the last chained record per session, so appends
65
- // stay O(1) instead of re-reading the file. Keyed per process — two processes
66
- // appending the SAME session concurrently could fork the chain (verify would
67
- // then flag it), but the store has always assumed a single writer per session.
68
- const _chainTailCache = new Map();
517
+ function inspectPhysicalTail(filePath, { dryRun = false } = {}) {
518
+ const result = {
519
+ action: "none",
520
+ changed: false,
521
+ discardedBytes: 0,
522
+ discardedRecords: 0,
523
+ fileSize: 0,
524
+ };
525
+ if (!existsSync(filePath)) return result;
526
+ const fd = openSync(filePath, "r+");
527
+ let normalizeNewline = false;
528
+ try {
529
+ const size = fstatSync(fd).size;
530
+ result.fileSize = size;
531
+ if (size > 0) {
532
+ const lastByte = Buffer.allocUnsafe(1);
533
+ readSync(fd, lastByte, 0, 1, size - 1);
534
+ if (lastByte[0] !== 0x0a) {
535
+ // Locate the beginning of the physical tail using bounded reverse IO.
536
+ let cursor = size;
537
+ let tailStart = 0;
538
+ let found = false;
539
+ const chunkSize = 64 * 1024;
540
+ while (cursor > 0 && !found) {
541
+ const length = Math.min(chunkSize, cursor);
542
+ cursor -= length;
543
+ const chunk = Buffer.allocUnsafe(length);
544
+ readSync(fd, chunk, 0, length, cursor);
545
+ const newline = chunk.lastIndexOf(0x0a);
546
+ if (newline >= 0) {
547
+ tailStart = cursor + newline + 1;
548
+ found = true;
549
+ }
550
+ }
551
+ const tail = Buffer.allocUnsafe(size - tailStart);
552
+ if (tail.length > 0) readSync(fd, tail, 0, tail.length, tailStart);
553
+ try {
554
+ const text = new TextDecoder("utf-8", { fatal: true }).decode(tail);
555
+ JSON.parse(text);
556
+ // A valid legacy/manual last record merely lacks its newline; retain
557
+ // it and normalize before appending the next chained event.
558
+ result.action = "normalize-newline";
559
+ result.changed = true;
560
+ normalizeNewline = !dryRun;
561
+ } catch {
562
+ // Crash tail: discard only the one incomplete physical record.
563
+ result.action = "discard-partial-record";
564
+ result.changed = true;
565
+ result.discardedBytes = size - tailStart;
566
+ result.discardedRecords = 1;
567
+ if (!dryRun) ftruncateSync(fd, tailStart);
568
+ }
569
+ }
570
+ }
571
+ } finally {
572
+ closeSync(fd);
573
+ }
574
+ if (normalizeNewline) {
575
+ appendFileSync(filePath, "\n", { encoding: "utf8", mode: 0o600 });
576
+ }
577
+ if (!dryRun && result.changed) ensurePrivateFile(filePath);
578
+ return result;
579
+ }
69
580
 
70
- function _resolveChainTail(sessionId, filePath) {
71
- if (!existsSync(filePath)) {
72
- // Missing file self-heals a stale cache entry (file deleted externally,
73
- // or a test tearing down the sessions dir between cases).
74
- _chainTailCache.set(sessionId, null);
75
- return null;
581
+ /**
582
+ * Repair at most one crash-truncated physical tail and resolve the chain hash.
583
+ * This is called only while the transcript's cross-process lock is held, so a
584
+ * second writer can never reuse a stale in-process hash.
585
+ */
586
+ function _resolveChainTail(filePath) {
587
+ if (!existsSync(filePath)) return { prevHash: null, recovery: null };
588
+ // Fast path: almost every append follows a newline-terminated record. Read
589
+ // that tail once and avoid opening the file a second time just to inspect its
590
+ // last byte. The slow repair path runs only for an unterminated crash tail.
591
+ const initial = iterateFileLinesReverseSync(filePath);
592
+ const first = initial.next();
593
+ if (!first.done && first.value.terminated) {
594
+ try {
595
+ let current = first;
596
+ while (!current.done) {
597
+ try {
598
+ const event = JSON.parse(current.value.line);
599
+ return {
600
+ prevHash: typeof event?.hash === "string" ? event.hash : null,
601
+ recovery: null,
602
+ };
603
+ } catch {
604
+ current = initial.next();
605
+ }
606
+ }
607
+ return { prevHash: null, recovery: null };
608
+ } finally {
609
+ initial.return?.();
610
+ }
76
611
  }
77
- const cached = _chainTailCache.get(sessionId);
78
- if (cached !== undefined) return cached;
79
- const tail = latestChainHash(readFileSync(filePath, "utf-8"));
80
- _chainTailCache.set(sessionId, tail);
81
- return tail;
612
+ initial.return?.();
613
+
614
+ const recovery = inspectPhysicalTail(filePath);
615
+ for (const { line } of iterateFileLinesReverseSync(filePath)) {
616
+ try {
617
+ const event = JSON.parse(line);
618
+ return {
619
+ prevHash: typeof event?.hash === "string" ? event.hash : null,
620
+ recovery: recovery.changed ? recovery : null,
621
+ };
622
+ } catch {
623
+ // A malformed historical line is verified separately; keep searching so
624
+ // this append never chains from arbitrary bytes.
625
+ }
626
+ }
627
+ return { prevHash: null, recovery: recovery.changed ? recovery : null };
82
628
  }
83
629
 
84
- export function appendEvent(sessionId, type, data) {
630
+ function readVerifiedWsAuthorityLocked(
631
+ sessionId,
632
+ filePath,
633
+ requestId,
634
+ inputDigest,
635
+ ) {
636
+ const reducer = createWsTurnLifecycleReducer({ requestId, inputDigest });
637
+ const verification = verifyTranscriptFile(filePath, {
638
+ onVerifiedEvent: (event) => reducer.accept(event),
639
+ });
640
+ assertVerifiedTranscriptAnchor(sessionId, verification);
641
+ const authority = Object.freeze({
642
+ headHash: verification.lastHash,
643
+ eventCount: verification.chainedEvents,
644
+ });
645
+ return Object.freeze({
646
+ authority,
647
+ state: reducer.finish(authority),
648
+ messages: Object.freeze([...rebuildVerifiedMessagesFromFile(filePath)]),
649
+ });
650
+ }
651
+
652
+ function appendVerifiedWsAuthorityEventLocked(
653
+ sessionId,
654
+ filePath,
655
+ type,
656
+ data,
657
+ previousHeadHash,
658
+ { verifySettlement = true } = {},
659
+ ) {
660
+ const core = { type, timestamp: Date.now(), data };
661
+ const hash = computeEventHash(previousHeadHash, core);
662
+ const event = { ...core, prevHash: previousHeadHash, hash };
663
+ appendFileSync(filePath, `${JSON.stringify(event)}\n`, {
664
+ encoding: "utf8",
665
+ mode: 0o600,
666
+ });
667
+ ensurePrivateFile(filePath);
668
+ runSessionScaleFaultHook("afterTranscriptAppend", {
669
+ sessionId,
670
+ type,
671
+ event,
672
+ filePath,
673
+ });
674
+ try {
675
+ recordSessionEvent(getSessionsDir(), sessionId, event, hash);
676
+ } catch (cause) {
677
+ const error = new Error(
678
+ `Session authority anchor could not be persisted: ${sessionId}`,
679
+ { cause },
680
+ );
681
+ error.code = "SESSION_INDEX_ANCHOR_FAILED";
682
+ error.sessionId = sessionId;
683
+ error.commitState = "unknown";
684
+ throw error;
685
+ }
686
+ if (verifySettlement) {
687
+ const verification = verifyTranscriptFile(filePath);
688
+ try {
689
+ assertVerifiedTranscriptAnchor(sessionId, verification);
690
+ } catch (cause) {
691
+ const error = new Error(
692
+ `Session authority settlement could not be verified: ${sessionId}`,
693
+ { cause },
694
+ );
695
+ error.code = "SESSION_INDEX_ANCHOR_FAILED";
696
+ error.sessionId = sessionId;
697
+ error.commitState = "unknown";
698
+ throw error;
699
+ }
700
+ if (verification.lastHash !== hash || event.prevHash !== previousHeadHash) {
701
+ const error = new Error(
702
+ `Session authority settlement head is not durable: ${sessionId}`,
703
+ );
704
+ error.code = "SESSION_INDEX_ANCHOR_FAILED";
705
+ error.sessionId = sessionId;
706
+ error.commitState = "unknown";
707
+ throw error;
708
+ }
709
+ }
710
+ return Object.freeze({ hash, event });
711
+ }
712
+
713
+ function withVerifiedWsTurnLock(sessionId, task) {
714
+ const filePath = sessionPath(sessionId);
715
+ return withFileLock(filePath, () => task(filePath), {
716
+ failIfUnavailable: true,
717
+ timeoutMs: 30_000,
718
+ retryMs: 1,
719
+ maxRetryMs: 8,
720
+ retryJitterMs: 4,
721
+ yieldAfterReleaseMs: 2,
722
+ });
723
+ }
724
+
725
+ function encodeEventMessageProvenance(type, data) {
726
+ if (
727
+ (type === "compact" || type === "checkpoint_timeline_commit") &&
728
+ data &&
729
+ typeof data === "object" &&
730
+ !Array.isArray(data) &&
731
+ Array.isArray(data.messages)
732
+ ) {
733
+ return {
734
+ ...data,
735
+ messages: data.messages.map(encodePersistedMessage),
736
+ };
737
+ }
738
+ if (type === "system") return encodePersistedMessage(data);
739
+ return data;
740
+ }
741
+
742
+ const SESSION_AUTHORITY_RECOVERY_TEXT_LIMITS = Object.freeze({
743
+ safetyId: 512,
744
+ safetyIdentity: 512,
745
+ safetyPlanIdentity: 1024,
746
+ safetyCoverage: 64,
747
+ restorePhase: 64,
748
+ branchSessionId: 512,
749
+ });
750
+ const SESSION_AUTHORITY_RECOVERY_CREATED_PATH_LIMIT = 256;
751
+ const SESSION_AUTHORITY_RECOVERY_PATH_LENGTH_LIMIT = 4096;
752
+
753
+ function sanitizeSessionAuthorityRecoveryEvidence(value) {
754
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
755
+ const evidence = {};
756
+ for (const [field, limit] of Object.entries(
757
+ SESSION_AUTHORITY_RECOVERY_TEXT_LIMITS,
758
+ )) {
759
+ if (typeof value[field] === "string" && value[field].length > 0) {
760
+ evidence[field] = value[field].slice(0, limit);
761
+ }
762
+ }
763
+ if (Array.isArray(value.createdPaths)) {
764
+ evidence.createdPaths = Object.freeze(
765
+ value.createdPaths
766
+ .filter((item) => typeof item === "string")
767
+ .slice(0, SESSION_AUTHORITY_RECOVERY_CREATED_PATH_LIMIT)
768
+ .map((item) =>
769
+ item.slice(0, SESSION_AUTHORITY_RECOVERY_PATH_LENGTH_LIMIT),
770
+ ),
771
+ );
772
+ }
773
+ return Object.keys(evidence).length > 0 ? Object.freeze(evidence) : null;
774
+ }
775
+
776
+ function appendEventLocked(
777
+ sessionId,
778
+ type,
779
+ data,
780
+ { expectedHeadHash, compareHead = false, requireIndexAnchor = false } = {},
781
+ ) {
85
782
  const filePath = sessionPath(sessionId);
86
- withFileLock(
783
+ return withFileLock(
87
784
  filePath,
88
785
  () => {
89
- const prevHash = _resolveChainTail(sessionId, filePath);
90
- const core = { type, timestamp: Date.now(), data };
786
+ const existingMeta = readSessionMeta(getSessionsDir(), sessionId);
787
+ if (
788
+ !existsSync(filePath) &&
789
+ existingMeta?.deleted === true &&
790
+ type !== "session_start"
791
+ ) {
792
+ const error = new Error(`Session was deleted: ${sessionId}`);
793
+ error.code = "SESSION_DELETED";
794
+ throw error;
795
+ }
796
+ const { prevHash, recovery } = _resolveChainTail(filePath);
797
+ if (compareHead && prevHash !== (expectedHeadHash || null)) {
798
+ const error = new Error(
799
+ `Session revision changed for ${sessionId}; refresh the checkpoint timeline`,
800
+ );
801
+ error.code = "SESSION_REVISION_STALE";
802
+ error.expectedHeadHash = expectedHeadHash || null;
803
+ error.actualHeadHash = prevHash;
804
+ throw error;
805
+ }
806
+ const persistedData = encodeEventMessageProvenance(type, data);
807
+ const core = { type, timestamp: Date.now(), data: persistedData };
91
808
  const hash = computeEventHash(prevHash, core);
92
- const line = JSON.stringify({ ...core, prevHash, hash }) + "\n";
93
- appendFileSync(filePath, line, "utf-8");
94
- _chainTailCache.set(sessionId, hash);
809
+ const event = { ...core, prevHash, hash };
810
+ const line = JSON.stringify(event) + "\n";
811
+ appendFileSync(filePath, line, { encoding: "utf8", mode: 0o600 });
812
+ ensurePrivateFile(filePath);
813
+ runSessionScaleFaultHook("afterTranscriptAppend", {
814
+ sessionId,
815
+ type,
816
+ event,
817
+ filePath,
818
+ });
819
+ try {
820
+ recordSessionEvent(getSessionsDir(), sessionId, event, hash);
821
+ } catch (cause) {
822
+ if (requireIndexAnchor) {
823
+ const error = new Error(
824
+ `Session authority anchor could not be persisted: ${sessionId}`,
825
+ { cause },
826
+ );
827
+ error.code = "SESSION_INDEX_ANCHOR_FAILED";
828
+ error.sessionId = sessionId;
829
+ throw error;
830
+ }
831
+ // The metadata index is explicitly rebuildable. Never turn a committed
832
+ // transcript event into an apparent failure that a caller may retry.
833
+ }
834
+ if (requireIndexAnchor) {
835
+ const verification = verifyTranscriptFile(filePath);
836
+ const anchoredMeta = readSessionMeta(getSessionsDir(), sessionId);
837
+ const anchored =
838
+ verification.status === TRANSCRIPT_CHAIN_STATUS.VERIFIED &&
839
+ verification.malformedLines === 0 &&
840
+ !verification.truncatedTail &&
841
+ anchoredMeta?.deleted !== true &&
842
+ anchoredMeta?.last_hash === hash &&
843
+ Number(anchoredMeta?.event_count) === verification.chainedEvents;
844
+ if (!anchored) {
845
+ const error = new Error(
846
+ `Session authority anchor does not match the transcript: ${sessionId}`,
847
+ );
848
+ error.code = "SESSION_INDEX_ANCHOR_FAILED";
849
+ error.sessionId = sessionId;
850
+ error.verification = verification;
851
+ throw error;
852
+ }
853
+ }
854
+ return { hash, recovery };
855
+ },
856
+ {
857
+ failIfUnavailable: true,
858
+ timeoutMs: 30_000,
859
+ retryMs: 1,
860
+ maxRetryMs: 8,
861
+ retryJitterMs: 4,
862
+ yieldAfterReleaseMs: 2,
95
863
  },
96
- { failIfUnavailable: true },
97
864
  );
98
865
  }
99
866
 
867
+ export function appendEvent(sessionId, type, data) {
868
+ return appendEventLocked(sessionId, type, data);
869
+ }
870
+
871
+ /**
872
+ * Append an authority-bearing event only when both the transcript and its
873
+ * crash/tail-truncation anchor are durably updated and still agree.
874
+ */
875
+ export function appendAuthorityEvent(sessionId, type, data) {
876
+ return appendEventLocked(sessionId, type, data, {
877
+ requireIndexAnchor: true,
878
+ });
879
+ }
880
+
881
+ /**
882
+ * Compare-and-append for revisioned IDE actions. The head check and append run
883
+ * under the transcript's canonical writer lock, so a stale preview can never
884
+ * commit after another session writer advances the chain.
885
+ */
886
+ export function appendEventIfHead(
887
+ sessionId,
888
+ type,
889
+ data,
890
+ expectedHeadHash = null,
891
+ ) {
892
+ return appendEventLocked(sessionId, type, data, {
893
+ expectedHeadHash,
894
+ compareHead: true,
895
+ });
896
+ }
897
+
898
+ /** Authority-bearing compare-and-append with a mandatory index anchor. */
899
+ export function appendAuthorityEventIfHead(
900
+ sessionId,
901
+ type,
902
+ data,
903
+ expectedHeadHash = null,
904
+ ) {
905
+ return appendEventLocked(sessionId, type, data, {
906
+ expectedHeadHash,
907
+ compareHead: true,
908
+ requireIndexAnchor: true,
909
+ });
910
+ }
911
+
912
+ /**
913
+ * Run a synchronous authority transaction while holding the canonical
914
+ * transcript writer lock for its whole lifetime.
915
+ *
916
+ * A sequence of separate compare-and-appends leaves a gap between commits:
917
+ * callers may mutate an external resource after the first event, then lose the
918
+ * second CAS to another session writer. This primitive closes that gap. It
919
+ * validates the transcript + sidecar anchor and the caller's expected head
920
+ * before invoking `task`, then keeps every authority append on the same locked
921
+ * chain. The callback must be synchronous; retaining the writer beyond the
922
+ * lock lifetime would make the apparent transaction unsafe.
923
+ *
924
+ * External mutations may register bounded, recovery-only evidence through the
925
+ * writer. It is attached only when final settlement is unverifiable; the
926
+ * callback's arbitrary return value is never copied into an error.
927
+ *
928
+ * This is a transaction-scoped writer lease, not a general host/session lease
929
+ * and not a filesystem power-loss transaction. Callers that coordinate an
930
+ * external resource must still journal crash recovery for that resource.
931
+ */
932
+ export function withSessionAuthorityTransaction(
933
+ sessionId,
934
+ expectedHeadHash,
935
+ task,
936
+ ) {
937
+ if (typeof task !== "function") {
938
+ throw new TypeError("Session authority transaction callback is required");
939
+ }
940
+ if (task.constructor?.name === "AsyncFunction") {
941
+ throw new TypeError(
942
+ "Session authority transaction callback must be synchronous",
943
+ );
944
+ }
945
+
946
+ const filePath = sessionPath(sessionId);
947
+ return withFileLock(
948
+ filePath,
949
+ () => {
950
+ const existingMeta = readSessionMeta(getSessionsDir(), sessionId);
951
+ if (!existsSync(filePath) && existingMeta?.deleted === true) {
952
+ const error = new Error(`Session was deleted: ${sessionId}`);
953
+ error.code = "SESSION_DELETED";
954
+ throw error;
955
+ }
956
+ if (!existsSync(filePath) && existingMeta !== null) {
957
+ throw unverifiedTranscriptError(sessionId, {
958
+ status: "missing",
959
+ reason: "session sidecar exists without its transcript",
960
+ lastHash: null,
961
+ chainedEvents: 0,
962
+ });
963
+ }
964
+
965
+ let currentHeadHash = null;
966
+ if (existsSync(filePath)) {
967
+ // Normalize or discard only an incomplete physical tail before the
968
+ // verified sample. The existing sidecar already anchors the last
969
+ // complete event, so a crash tail never becomes transaction authority.
970
+ _resolveChainTail(filePath);
971
+ const verification = verifyTranscriptFile(filePath);
972
+ assertVerifiedTranscriptAnchor(sessionId, verification);
973
+ currentHeadHash = verification.lastHash;
974
+ }
975
+
976
+ const expected = expectedHeadHash || null;
977
+ if (currentHeadHash !== expected) {
978
+ const error = new Error(
979
+ `Session revision changed for ${sessionId}; refresh the checkpoint timeline`,
980
+ );
981
+ error.code = "SESSION_REVISION_STALE";
982
+ error.expectedHeadHash = expected;
983
+ error.actualHeadHash = currentHeadHash;
984
+ throw error;
985
+ }
986
+
987
+ let appendAttempts = 0;
988
+ let writerActive = true;
989
+ let writerPoisoned = false;
990
+ let transactionRecoveryEvidence = null;
991
+ const writer = Object.freeze({
992
+ initialHeadHash: currentHeadHash,
993
+ currentHeadHash: () => currentHeadHash,
994
+ retainRecoveryEvidence(evidence) {
995
+ if (!writerActive) {
996
+ const error = new Error(
997
+ `Session authority transaction is already closed: ${sessionId}`,
998
+ );
999
+ error.code = "SESSION_AUTHORITY_TRANSACTION_CLOSED";
1000
+ throw error;
1001
+ }
1002
+ const sanitized = sanitizeSessionAuthorityRecoveryEvidence(evidence);
1003
+ if (sanitized) {
1004
+ transactionRecoveryEvidence = Object.freeze({
1005
+ ...(transactionRecoveryEvidence || {}),
1006
+ ...sanitized,
1007
+ });
1008
+ }
1009
+ return transactionRecoveryEvidence;
1010
+ },
1011
+ appendAuthorityEvent(type, data) {
1012
+ if (!writerActive) {
1013
+ const error = new Error(
1014
+ `Session authority transaction is already closed: ${sessionId}`,
1015
+ );
1016
+ error.code = "SESSION_AUTHORITY_TRANSACTION_CLOSED";
1017
+ throw error;
1018
+ }
1019
+ if (writerPoisoned) {
1020
+ const error = new Error(
1021
+ `Session authority transaction writer is poisoned: ${sessionId}`,
1022
+ );
1023
+ error.code = "SESSION_AUTHORITY_TRANSACTION_POISONED";
1024
+ error.commitState = "unknown";
1025
+ throw error;
1026
+ }
1027
+ const persistedData = encodeEventMessageProvenance(type, data);
1028
+ appendAttempts += 1;
1029
+ let appended;
1030
+ try {
1031
+ appended = appendVerifiedWsAuthorityEventLocked(
1032
+ sessionId,
1033
+ filePath,
1034
+ type,
1035
+ persistedData,
1036
+ currentHeadHash,
1037
+ { verifySettlement: false },
1038
+ );
1039
+ } catch (error) {
1040
+ // Bytes may already have reached the transcript before a
1041
+ // sidecar/activity failure. The actual head is then unknown, so a
1042
+ // second append from this writer would risk forking the chain.
1043
+ writerPoisoned = true;
1044
+ if (error && !error.commitState) error.commitState = "unknown";
1045
+ throw error;
1046
+ }
1047
+ currentHeadHash = appended.hash;
1048
+ return appended;
1049
+ },
1050
+ });
1051
+
1052
+ let result;
1053
+ let bodyError = null;
1054
+ let bodyThrew = false;
1055
+ try {
1056
+ result = task(writer);
1057
+ if (result && typeof result.then === "function") {
1058
+ const error = new TypeError(
1059
+ "Session authority transaction callback must be synchronous",
1060
+ );
1061
+ error.code = "SESSION_AUTHORITY_TRANSACTION_ASYNC";
1062
+ error.commitState = "unknown";
1063
+ throw error;
1064
+ }
1065
+ } catch (error) {
1066
+ bodyThrew = true;
1067
+ bodyError = error;
1068
+ } finally {
1069
+ // A callback can retain this object or return a Promise/thenable. Revoke
1070
+ // it before settlement and before releasing the lock so no later
1071
+ // microtask can append outside the transaction critical section.
1072
+ writerActive = false;
1073
+ }
1074
+
1075
+ if (appendAttempts > 0) {
1076
+ try {
1077
+ const verification = verifyTranscriptFile(filePath);
1078
+ assertVerifiedTranscriptAnchor(sessionId, verification);
1079
+ if (verification.lastHash !== currentHeadHash) {
1080
+ throw new Error(
1081
+ `Session authority settlement head is not durable: ${sessionId}`,
1082
+ );
1083
+ }
1084
+ } catch (cause) {
1085
+ const error = new Error(
1086
+ `Session authority transaction could not be verified: ${sessionId}`,
1087
+ { cause },
1088
+ );
1089
+ error.code = "SESSION_INDEX_ANCHOR_FAILED";
1090
+ error.sessionId = sessionId;
1091
+ error.commitState = "unknown";
1092
+ if (bodyThrew) error.transactionError = bodyError;
1093
+ if (transactionRecoveryEvidence) {
1094
+ // Never attach the arbitrary callback result. Callers must opt in
1095
+ // to this bounded recovery-only projection before settlement.
1096
+ error.transactionRecoveryEvidence = transactionRecoveryEvidence;
1097
+ }
1098
+ throw error;
1099
+ }
1100
+ }
1101
+ if (bodyThrew) throw bodyError;
1102
+ return result;
1103
+ },
1104
+ {
1105
+ failIfUnavailable: true,
1106
+ timeoutMs: 30_000,
1107
+ retryMs: 1,
1108
+ maxRetryMs: 8,
1109
+ retryJitterMs: 4,
1110
+ yieldAfterReleaseMs: 2,
1111
+ },
1112
+ );
1113
+ }
1114
+
1115
+ function verifyTranscriptFile(filePath, options = {}) {
1116
+ const onVerifiedEvent =
1117
+ typeof options.onVerifiedEvent === "function"
1118
+ ? options.onVerifiedEvent
1119
+ : null;
1120
+ const ioMetrics = options.ioMetrics ?? null;
1121
+ const result = {
1122
+ status: TRANSCRIPT_CHAIN_STATUS.EMPTY,
1123
+ chainedEvents: 0,
1124
+ legacyEvents: 0,
1125
+ malformedLines: 0,
1126
+ truncatedTail: false,
1127
+ firstInvalidLine: null,
1128
+ reason: null,
1129
+ lastHash: null,
1130
+ };
1131
+ let lastHash = null;
1132
+ let sawChain = false;
1133
+ const tampered = (lineNo, reason) => ({
1134
+ ...result,
1135
+ status: TRANSCRIPT_CHAIN_STATUS.TAMPERED,
1136
+ firstInvalidLine: lineNo,
1137
+ reason,
1138
+ });
1139
+
1140
+ for (const { line, lineNo, terminated } of iterateFileLinesSync(filePath, {
1141
+ ioMetrics,
1142
+ })) {
1143
+ let event;
1144
+ try {
1145
+ event = JSON.parse(line);
1146
+ } catch {
1147
+ result.malformedLines += 1;
1148
+ if (!terminated) {
1149
+ result.truncatedTail = true;
1150
+ continue;
1151
+ }
1152
+ if (sawChain) {
1153
+ return tampered(lineNo, "malformed line inside hash chain");
1154
+ }
1155
+ continue;
1156
+ }
1157
+
1158
+ if (event && typeof event.hash === "string") {
1159
+ const expectedPrev = sawChain ? lastHash : null;
1160
+ if ((event.prevHash ?? null) !== expectedPrev) {
1161
+ return tampered(
1162
+ lineNo,
1163
+ sawChain
1164
+ ? "hash chain linkage broken (record deleted, inserted, or reordered)"
1165
+ : "chain does not start at a genesis record (head records removed)",
1166
+ );
1167
+ }
1168
+ if (computeEventHash(expectedPrev, event) !== event.hash) {
1169
+ return tampered(lineNo, "event content does not match its hash");
1170
+ }
1171
+ sawChain = true;
1172
+ lastHash = event.hash;
1173
+ result.lastHash = lastHash;
1174
+ result.chainedEvents += 1;
1175
+ onVerifiedEvent?.(event);
1176
+ } else {
1177
+ if (sawChain) {
1178
+ return tampered(
1179
+ lineNo,
1180
+ "unchained record after hash chain started (manual append or downgrade write)",
1181
+ );
1182
+ }
1183
+ result.legacyEvents += 1;
1184
+ }
1185
+ }
1186
+
1187
+ if (result.chainedEvents > 0) {
1188
+ result.status =
1189
+ result.legacyEvents > 0
1190
+ ? TRANSCRIPT_CHAIN_STATUS.PARTIAL
1191
+ : TRANSCRIPT_CHAIN_STATUS.VERIFIED;
1192
+ } else if (result.legacyEvents > 0 || result.malformedLines > 0) {
1193
+ result.status = TRANSCRIPT_CHAIN_STATUS.LEGACY;
1194
+ }
1195
+ return result;
1196
+ }
1197
+
100
1198
  /**
101
1199
  * Verify a session transcript's hash chain (tamper-evidence).
102
1200
  * Statuses: verified | partial (legacy prefix + valid chain) | legacy
@@ -112,7 +1210,7 @@ export function verifySession(sessionId) {
112
1210
  }
113
1211
  return {
114
1212
  sessionId,
115
- ...verifyTranscriptText(readFileSync(filePath, "utf-8")),
1213
+ ...verifyTranscriptFile(filePath),
116
1214
  };
117
1215
  }
118
1216
 
@@ -125,6 +1223,137 @@ export function verifyAllSessions(options = {}) {
125
1223
  .map((file) => verifySession(basename(file, ".jsonl")));
126
1224
  }
127
1225
 
1226
+ function indexedProjectionMatchesTranscript(meta, verification, validation) {
1227
+ return (
1228
+ verification.status === TRANSCRIPT_CHAIN_STATUS.VERIFIED &&
1229
+ validation.valid === true &&
1230
+ meta?.deleted !== true &&
1231
+ Number(meta?.event_count) === validation.eventCount &&
1232
+ (meta?.last_hash ?? null) === (verification.lastHash ?? null)
1233
+ );
1234
+ }
1235
+
1236
+ /**
1237
+ * Diagnose and repair the final physical transcript record plus its rebuildable
1238
+ * metadata projection. Interior corruption and hash-chain tampering are never
1239
+ * rewritten: repair may append a missing newline, discard one crash-partial
1240
+ * record, or rebuild a stale sidecar/activity index from a healthy transcript.
1241
+ */
1242
+ export function repairSession(sessionId, options = {}) {
1243
+ const dryRun = options.dryRun === true;
1244
+ if (isUnsafeSessionId(sessionId)) {
1245
+ return {
1246
+ sessionId,
1247
+ dryRun,
1248
+ changed: false,
1249
+ healthy: false,
1250
+ status: "invalid-id",
1251
+ reason: "invalid session id",
1252
+ };
1253
+ }
1254
+ const filePath = sessionPath(sessionId);
1255
+ if (!existsSync(filePath)) {
1256
+ return {
1257
+ sessionId,
1258
+ dryRun,
1259
+ changed: false,
1260
+ healthy: false,
1261
+ status: "not-found",
1262
+ reason: "session file not found",
1263
+ };
1264
+ }
1265
+
1266
+ const result = withFileLock(
1267
+ filePath,
1268
+ () => {
1269
+ const dir = getSessionsDir();
1270
+ const before = verifyTranscriptFile(filePath);
1271
+ const beforeValidation = validateJsonlSession(sessionId);
1272
+ const repair = inspectPhysicalTail(filePath, { dryRun });
1273
+ const after = dryRun ? null : verifyTranscriptFile(filePath);
1274
+ const afterValidation = dryRun ? null : validateJsonlSession(sessionId);
1275
+ const effective = after || before;
1276
+ const effectiveValidation = afterValidation || beforeValidation;
1277
+ const transcriptHealthy =
1278
+ effective.status !== TRANSCRIPT_CHAIN_STATUS.TAMPERED &&
1279
+ !(dryRun
1280
+ ? before.truncatedTail || repair.changed
1281
+ : after.truncatedTail) &&
1282
+ effectiveValidation.valid;
1283
+ const currentMeta = readSessionMeta(dir, sessionId);
1284
+ const currentActivity = readLatestSessionActivity(dir, sessionId);
1285
+ const indexRepairRequired =
1286
+ transcriptHealthy &&
1287
+ (!indexedProjectionMatchesTranscript(
1288
+ currentMeta,
1289
+ effective,
1290
+ effectiveValidation,
1291
+ ) ||
1292
+ !indexedProjectionMatchesTranscript(
1293
+ currentActivity,
1294
+ effective,
1295
+ effectiveValidation,
1296
+ ));
1297
+ let indexRebuilt = false;
1298
+ let indexRepairError = null;
1299
+ if (!dryRun && indexRepairRequired) {
1300
+ try {
1301
+ rebuildSessionMetaUnlocked(dir, sessionId, filePath);
1302
+ indexRebuilt = true;
1303
+ } catch (cause) {
1304
+ indexRepairError = {
1305
+ code: cause?.code || null,
1306
+ message: String(cause?.message || cause),
1307
+ };
1308
+ }
1309
+ }
1310
+ const healthy = dryRun
1311
+ ? transcriptHealthy && !repair.changed && !indexRepairRequired
1312
+ : transcriptHealthy && (!indexRepairRequired || indexRebuilt);
1313
+ return {
1314
+ sessionId,
1315
+ dryRun,
1316
+ changed: repair.changed || indexRebuilt,
1317
+ physicalChanged: repair.changed,
1318
+ indexChanged: indexRebuilt,
1319
+ wouldChange: dryRun && (repair.changed || indexRepairRequired),
1320
+ action:
1321
+ repair.action === "none" && indexRepairRequired
1322
+ ? "rebuild-index"
1323
+ : repair.action,
1324
+ physicalAction: repair.action,
1325
+ indexAction: indexRepairRequired ? "rebuild-index" : "none",
1326
+ indexRepairRequired,
1327
+ indexRebuilt,
1328
+ indexRepairError,
1329
+ discardedBytes: repair.discardedBytes,
1330
+ discardedRecords: repair.discardedRecords,
1331
+ healthy,
1332
+ transcriptHealthy,
1333
+ status: effective.status,
1334
+ before,
1335
+ after,
1336
+ beforeValidation,
1337
+ afterValidation,
1338
+ reason:
1339
+ effective.status === TRANSCRIPT_CHAIN_STATUS.TAMPERED
1340
+ ? effective.reason || "transcript remains tampered"
1341
+ : !effectiveValidation.valid
1342
+ ? effectiveValidation.reason ||
1343
+ "transcript remains structurally invalid"
1344
+ : indexRepairError
1345
+ ? `session metadata index rebuild failed: ${indexRepairError.message}`
1346
+ : indexRepairRequired && dryRun
1347
+ ? "session metadata index requires rebuild"
1348
+ : null,
1349
+ };
1350
+ },
1351
+ { failIfUnavailable: true },
1352
+ );
1353
+
1354
+ return result;
1355
+ }
1356
+
128
1357
  /** All locally-stored session ids (the source of truth a mirror derives from). */
129
1358
  export function listSessionIds() {
130
1359
  const dir = getSessionsDir();
@@ -134,6 +1363,20 @@ export function listSessionIds() {
134
1363
  .map((file) => basename(file, ".jsonl"));
135
1364
  }
136
1365
 
1366
+ /** Resolve an exact id or one unambiguous prefix without reading transcripts. */
1367
+ export function resolveSessionId(input) {
1368
+ if (isUnsafeSessionId(input)) return null;
1369
+ if (sessionExists(input)) return input;
1370
+ const matches = listSessionIds().filter((id) => id.startsWith(input));
1371
+ if (matches.length > 1) {
1372
+ const error = new Error(`Ambiguous session id prefix: ${input}`);
1373
+ error.code = "AMBIGUOUS_SESSION_ID";
1374
+ error.matches = matches.slice(0, 20);
1375
+ throw error;
1376
+ }
1377
+ return matches[0] || null;
1378
+ }
1379
+
137
1380
  export { TRANSCRIPT_CHAIN_STATUS };
138
1381
 
139
1382
  export function startSession(sessionId, meta = {}) {
@@ -141,21 +1384,248 @@ export function startSession(sessionId, meta = {}) {
141
1384
  sessionId ||
142
1385
  `session-${Date.now()}-${createHash("sha256").update(Math.random().toString()).digest("hex").slice(0, 6)}`;
143
1386
 
144
- appendEvent(id, "session_start", {
145
- title: meta.title || "Untitled",
146
- provider: meta.provider || "",
147
- model: meta.model || "",
1387
+ appendEvent(id, "session_start", {
1388
+ title: meta.title || "Untitled",
1389
+ provider: meta.provider || "",
1390
+ model: meta.model || "",
1391
+ });
1392
+
1393
+ return id;
1394
+ }
1395
+
1396
+ export function appendUserMessage(sessionId, content) {
1397
+ appendEvent(sessionId, "user_message", { role: "user", content });
1398
+ }
1399
+
1400
+ export function appendAssistantMessage(sessionId, content) {
1401
+ appendEvent(sessionId, "assistant_message", { role: "assistant", content });
1402
+ }
1403
+
1404
+ function wsTurnLifecycleResult(snapshot, overrides = {}) {
1405
+ return Object.freeze({
1406
+ ...snapshot.state,
1407
+ messages: snapshot.messages,
1408
+ ...overrides,
1409
+ });
1410
+ }
1411
+
1412
+ /**
1413
+ * Claim one canonical WebSocket request before any model/tool execution.
1414
+ * Full chain verification, metadata-anchor validation, expected-head CAS and
1415
+ * claim append all execute under the cross-process transcript writer lock.
1416
+ */
1417
+ export function claimWsTurnIfHead(
1418
+ sessionId,
1419
+ { requestId, user, inputDigest, opaqueClaimId },
1420
+ expectedHeadHash = null,
1421
+ ) {
1422
+ const normalizedUser = normalizeWsTurnContent(user, "user");
1423
+ const computedDigest = computeWsTurnInputDigest(normalizedUser);
1424
+ const canonicalDigest = normalizeWsTurnInputDigest(
1425
+ inputDigest || computedDigest,
1426
+ );
1427
+ if (canonicalDigest !== computedDigest) {
1428
+ throw wsTurnAuthorityError(
1429
+ "WebSocket request input does not match its proposed claim digest",
1430
+ "CC_WS_TURN_INPUT_DIGEST_MISMATCH",
1431
+ );
1432
+ }
1433
+ const data = canonicalWsTurnClaimData({
1434
+ requestId,
1435
+ inputDigest: canonicalDigest,
1436
+ opaqueClaimId,
148
1437
  });
149
1438
 
150
- return id;
1439
+ return withVerifiedWsTurnLock(sessionId, (filePath) => {
1440
+ const snapshot = readVerifiedWsAuthorityLocked(
1441
+ sessionId,
1442
+ filePath,
1443
+ data.requestId,
1444
+ data.inputDigest,
1445
+ );
1446
+ if (snapshot.state.status !== "none") {
1447
+ return wsTurnLifecycleResult(snapshot, {
1448
+ acquired: false,
1449
+ deduplicated: true,
1450
+ });
1451
+ }
1452
+ if (snapshot.authority.headHash !== (expectedHeadHash || null)) {
1453
+ const error = new Error(
1454
+ `Session revision changed for ${sessionId}; refresh the canonical turn authority`,
1455
+ );
1456
+ error.code = "SESSION_REVISION_STALE";
1457
+ error.expectedHeadHash = expectedHeadHash || null;
1458
+ error.actualHeadHash = snapshot.authority.headHash;
1459
+ throw error;
1460
+ }
1461
+ const appended = appendVerifiedWsAuthorityEventLocked(
1462
+ sessionId,
1463
+ filePath,
1464
+ WS_TURN_CLAIM_EVENT,
1465
+ data,
1466
+ snapshot.authority.headHash,
1467
+ );
1468
+ const claim = projectWsTurnClaim(appended.event);
1469
+ return Object.freeze({
1470
+ ...snapshot.state,
1471
+ status: "pending",
1472
+ claim,
1473
+ headHash: appended.hash,
1474
+ eventCount: snapshot.authority.eventCount + 1,
1475
+ messages: snapshot.messages,
1476
+ acquired: true,
1477
+ deduplicated: false,
1478
+ });
1479
+ });
151
1480
  }
152
1481
 
153
- export function appendUserMessage(sessionId, content) {
154
- appendEvent(sessionId, "user_message", { role: "user", content });
1482
+ /** Settle only the exact durable claim that fenced model/tool execution. */
1483
+ export function settleWsTurnClaim(
1484
+ sessionId,
1485
+ {
1486
+ requestId,
1487
+ inputDigest,
1488
+ opaqueClaimId,
1489
+ outcome,
1490
+ user,
1491
+ assistant,
1492
+ failureCode = "CC_WS_TURN_FAILED",
1493
+ },
1494
+ ) {
1495
+ const identity = canonicalWsTurnClaimData({
1496
+ requestId,
1497
+ inputDigest,
1498
+ opaqueClaimId,
1499
+ });
1500
+ const data =
1501
+ outcome === "completed"
1502
+ ? canonicalWsTurnCompletedData({
1503
+ ...identity,
1504
+ user,
1505
+ assistant,
1506
+ })
1507
+ : outcome === "failed"
1508
+ ? canonicalWsTurnFailedData({
1509
+ ...identity,
1510
+ failureCode,
1511
+ })
1512
+ : (() => {
1513
+ throw wsTurnAuthorityError(
1514
+ "WebSocket turn settlement outcome is invalid",
1515
+ "CC_WS_TURN_OUTCOME_INVALID",
1516
+ );
1517
+ })();
1518
+
1519
+ return withVerifiedWsTurnLock(sessionId, (filePath) => {
1520
+ const snapshot = readVerifiedWsAuthorityLocked(
1521
+ sessionId,
1522
+ filePath,
1523
+ identity.requestId,
1524
+ identity.inputDigest,
1525
+ );
1526
+ if (snapshot.state.settlement) {
1527
+ if (snapshot.state.settlement.opaqueClaimId !== identity.opaqueClaimId) {
1528
+ throw wsTurnAuthorityError(
1529
+ "WebSocket request is settled by a different durable claim",
1530
+ "CC_WS_TURN_CLAIM_NOT_OWNER",
1531
+ );
1532
+ }
1533
+ return wsTurnLifecycleResult(snapshot, {
1534
+ acquired: false,
1535
+ deduplicated: true,
1536
+ });
1537
+ }
1538
+ if (
1539
+ snapshot.state.status !== "pending" ||
1540
+ snapshot.state.claim?.opaqueClaimId !== identity.opaqueClaimId
1541
+ ) {
1542
+ throw wsTurnAuthorityError(
1543
+ "WebSocket request has no matching pending claim to settle",
1544
+ "CC_WS_TURN_CLAIM_NOT_OWNER",
1545
+ );
1546
+ }
1547
+ const appended = appendVerifiedWsAuthorityEventLocked(
1548
+ sessionId,
1549
+ filePath,
1550
+ WS_TURN_EVENT,
1551
+ data,
1552
+ snapshot.authority.headHash,
1553
+ );
1554
+ const settlement = projectWsTurnSettlement(appended.event);
1555
+ return Object.freeze({
1556
+ ...snapshot.state,
1557
+ status: settlement.outcome,
1558
+ settlement,
1559
+ turn: settlement.outcome === "completed" ? settlement : null,
1560
+ headHash: appended.hash,
1561
+ eventCount: snapshot.authority.eventCount + 1,
1562
+ messages:
1563
+ settlement.outcome === "completed"
1564
+ ? Object.freeze([
1565
+ ...snapshot.messages,
1566
+ settlement.user,
1567
+ settlement.assistant,
1568
+ ])
1569
+ : snapshot.messages,
1570
+ acquired: false,
1571
+ deduplicated: false,
1572
+ });
1573
+ });
155
1574
  }
156
1575
 
157
- export function appendAssistantMessage(sessionId, content) {
158
- appendEvent(sessionId, "assistant_message", { role: "assistant", content });
1576
+ /**
1577
+ * Compatibility-only atomic settlement for callers that already hold a full
1578
+ * response. Canonical WS handlers must use claimWsTurnIfHead() first.
1579
+ */
1580
+ export function appendWsTurnIfHead(
1581
+ sessionId,
1582
+ { requestId, user, assistant },
1583
+ expectedHeadHash = null,
1584
+ ) {
1585
+ const data = canonicalWsTurnCompletedData({ requestId, user, assistant });
1586
+ const inputDigest = computeWsTurnInputDigest(data.user.content);
1587
+ return withVerifiedWsTurnLock(sessionId, (filePath) => {
1588
+ const snapshot = readVerifiedWsAuthorityLocked(
1589
+ sessionId,
1590
+ filePath,
1591
+ data.requestId,
1592
+ inputDigest,
1593
+ );
1594
+ if (snapshot.state.status === "completed") {
1595
+ return Object.freeze({
1596
+ ...snapshot.state.turn,
1597
+ hash: snapshot.state.turn.eventHash,
1598
+ deduplicated: true,
1599
+ });
1600
+ }
1601
+ if (snapshot.state.status !== "none") {
1602
+ throw wsTurnAuthorityError(
1603
+ "WebSocket request already has claimed lifecycle authority",
1604
+ "CC_WS_TURN_CLAIM_REQUIRED",
1605
+ );
1606
+ }
1607
+ if (snapshot.authority.headHash !== (expectedHeadHash || null)) {
1608
+ const error = new Error(
1609
+ `Session revision changed for ${sessionId}; refresh the checkpoint timeline`,
1610
+ );
1611
+ error.code = "SESSION_REVISION_STALE";
1612
+ error.expectedHeadHash = expectedHeadHash || null;
1613
+ error.actualHeadHash = snapshot.authority.headHash;
1614
+ throw error;
1615
+ }
1616
+ const appended = appendVerifiedWsAuthorityEventLocked(
1617
+ sessionId,
1618
+ filePath,
1619
+ WS_TURN_EVENT,
1620
+ data,
1621
+ snapshot.authority.headHash,
1622
+ );
1623
+ return Object.freeze({
1624
+ ...projectWsTurnMessages(appended.event),
1625
+ hash: appended.hash,
1626
+ deduplicated: false,
1627
+ });
1628
+ });
159
1629
  }
160
1630
 
161
1631
  export function appendToolCall(sessionId, toolName, args) {
@@ -242,19 +1712,59 @@ export function appendCompactEvent(sessionId, stats) {
242
1712
  appendEvent(sessionId, "compact", stats);
243
1713
  }
244
1714
 
1715
+ function canonicalReplayFingerprint(messages) {
1716
+ return JSON.stringify(
1717
+ projectCanonicalResumeMessages(messages, { strict: true }).map(
1718
+ encodePersistedMessage,
1719
+ ),
1720
+ );
1721
+ }
1722
+
1723
+ /**
1724
+ * Persist a compact checkpoint only while the verified active replay still
1725
+ * equals the caller's last known projection. Head-only CAS is insufficient for
1726
+ * a long-lived REPL: it could sample a newer head that already contains turns
1727
+ * absent from its local context and then accidentally hide those turns.
1728
+ */
1729
+ export function appendCompactEventIfMessagesMatch(
1730
+ sessionId,
1731
+ stats,
1732
+ expectedMessages,
1733
+ ) {
1734
+ const expectedFingerprint = canonicalReplayFingerprint(expectedMessages);
1735
+ const persistedData = encodeEventMessageProvenance("compact", stats);
1736
+ return withVerifiedWsTurnLock(sessionId, (filePath) => {
1737
+ const verification = verifyTranscriptFile(filePath);
1738
+ assertVerifiedTranscriptAnchor(sessionId, verification);
1739
+ const actualMessages = rebuildVerifiedMessagesFromFile(filePath);
1740
+ if (canonicalReplayFingerprint(actualMessages) !== expectedFingerprint) {
1741
+ const error = new Error(
1742
+ `Session messages changed for ${sessionId}; refresh before compacting`,
1743
+ );
1744
+ error.code = "SESSION_REVISION_STALE";
1745
+ error.actualHeadHash = verification.lastHash;
1746
+ throw error;
1747
+ }
1748
+ return appendVerifiedWsAuthorityEventLocked(
1749
+ sessionId,
1750
+ filePath,
1751
+ "compact",
1752
+ persistedData,
1753
+ verification.lastHash,
1754
+ );
1755
+ });
1756
+ }
1757
+
245
1758
  export function readEvents(sessionId) {
246
1759
  if (isUnsafeSessionId(sessionId)) return []; // traversal id → treat as empty
247
1760
  const filePath = sessionPath(sessionId);
248
1761
  if (!existsSync(filePath)) return [];
249
1762
 
250
- const content = readFileSync(filePath, "utf-8");
251
1763
  const events = [];
252
-
253
- for (const line of content.split("\n")) {
254
- if (!line.trim()) continue;
1764
+ for (const { line } of iterateFileLinesSync(filePath)) {
255
1765
  try {
256
1766
  events.push(JSON.parse(line));
257
- } catch (_e) {
1767
+ } catch {
258
1768
  // Skip malformed lines
259
1769
  }
260
1770
  }
@@ -262,6 +1772,216 @@ export function readEvents(sessionId) {
262
1772
  return events;
263
1773
  }
264
1774
 
1775
+ function unverifiedTranscriptError(sessionId, verification) {
1776
+ const error = new Error(
1777
+ `session transcript is not fully verified and anchored (${verification.status})`,
1778
+ );
1779
+ error.code = "SESSION_TRANSCRIPT_UNVERIFIED";
1780
+ error.sessionId = sessionId;
1781
+ error.verification = verification;
1782
+ return error;
1783
+ }
1784
+
1785
+ function assertVerifiedTranscriptAnchor(sessionId, verification) {
1786
+ const meta = readSessionMeta(getSessionsDir(), sessionId);
1787
+ const trustedStatus =
1788
+ verification.status === TRANSCRIPT_CHAIN_STATUS.VERIFIED;
1789
+ const anchoredHead =
1790
+ meta?.deleted !== true &&
1791
+ meta?.last_hash === verification.lastHash &&
1792
+ Number(meta?.event_count) === verification.chainedEvents;
1793
+ if (
1794
+ !trustedStatus ||
1795
+ !anchoredHead ||
1796
+ verification.malformedLines > 0 ||
1797
+ verification.truncatedTail
1798
+ ) {
1799
+ throw unverifiedTranscriptError(sessionId, verification);
1800
+ }
1801
+ }
1802
+
1803
+ /**
1804
+ * Fold a fully verified transcript into a caller-owned projection.
1805
+ *
1806
+ * The projection factory and its `accept(event)` / `finish(authority)` methods
1807
+ * execute while the canonical writer lock is held. Hash-chain verification,
1808
+ * event reduction, and the independently persisted head/count check share one
1809
+ * forward transcript pass. `finish()` may call `authority.readMessages()` to
1810
+ * recover only the active context with bounded reverse IO (latest compact
1811
+ * checkpoint plus its suffix).
1812
+ *
1813
+ * This removes the mandatory all-event array from resume projections. It does
1814
+ * not make verification IO sublinear: authenticating a plain chained JSONL
1815
+ * transcript remains O(N), and the local sidecar is not an anti-rollback
1816
+ * anchor or a cross-process lease.
1817
+ */
1818
+ export function readVerifiedProjection(
1819
+ sessionId,
1820
+ createProjection,
1821
+ options = {},
1822
+ ) {
1823
+ if (typeof createProjection !== "function") {
1824
+ throw new TypeError("Verified transcript projection factory is required");
1825
+ }
1826
+ if (isUnsafeSessionId(sessionId)) {
1827
+ const error = new Error(
1828
+ `unsafe session id: ${String(sessionId).slice(0, 60)}`,
1829
+ );
1830
+ error.code = "SESSION_TRANSCRIPT_UNVERIFIED";
1831
+ error.sessionId = sessionId;
1832
+ throw error;
1833
+ }
1834
+ const filePath = sessionPath(sessionId);
1835
+
1836
+ return withFileLock(
1837
+ filePath,
1838
+ () => {
1839
+ const projection = createProjection();
1840
+ if (
1841
+ !projection ||
1842
+ typeof projection.accept !== "function" ||
1843
+ typeof projection.finish !== "function"
1844
+ ) {
1845
+ throw new TypeError(
1846
+ "Verified transcript projection must provide accept() and finish()",
1847
+ );
1848
+ }
1849
+ if (!existsSync(filePath)) {
1850
+ return projection.finish(
1851
+ Object.freeze({
1852
+ headHash: null,
1853
+ eventCount: 0,
1854
+ readMessages: () => [],
1855
+ }),
1856
+ );
1857
+ }
1858
+ const verification = verifyTranscriptFile(filePath, {
1859
+ ioMetrics: options.ioMetrics,
1860
+ onVerifiedEvent: (event) => projection.accept(event),
1861
+ });
1862
+ assertVerifiedTranscriptAnchor(sessionId, verification);
1863
+ return projection.finish(
1864
+ Object.freeze({
1865
+ headHash: verification.lastHash,
1866
+ eventCount: verification.chainedEvents,
1867
+ readMessages: () =>
1868
+ rebuildVerifiedMessagesFromFile(filePath, {
1869
+ ioMetrics: options.messageIoMetrics,
1870
+ }),
1871
+ }),
1872
+ );
1873
+ },
1874
+ {
1875
+ failIfUnavailable: true,
1876
+ timeoutMs: 30_000,
1877
+ retryMs: 1,
1878
+ maxRetryMs: 8,
1879
+ retryJitterMs: 4,
1880
+ yieldAfterReleaseMs: 2,
1881
+ },
1882
+ );
1883
+ }
1884
+
1885
+ /**
1886
+ * Compatibility reader for consumers that still require the complete event
1887
+ * array. New resume projections should use `readVerifiedProjection()` so their
1888
+ * heap is bounded by active context plus reducer authority state.
1889
+ */
1890
+ export function readVerifiedEvents(sessionId) {
1891
+ return readVerifiedProjection(sessionId, () => {
1892
+ const events = [];
1893
+ return {
1894
+ accept(event) {
1895
+ events.push(event);
1896
+ },
1897
+ finish() {
1898
+ return events;
1899
+ },
1900
+ };
1901
+ });
1902
+ }
1903
+
1904
+ /**
1905
+ * Read the active context only after transcript and sidecar-anchor validation.
1906
+ * Unlike the compatibility `rebuildMessages()` reader, this is allowed to
1907
+ * restore hash-covered durable system-message provenance.
1908
+ */
1909
+ export function readVerifiedMessages(sessionId, options = {}) {
1910
+ return readVerifiedProjection(
1911
+ sessionId,
1912
+ () => ({
1913
+ accept() {},
1914
+ finish(authority) {
1915
+ return Object.freeze([...authority.readMessages()]);
1916
+ },
1917
+ }),
1918
+ options,
1919
+ );
1920
+ }
1921
+
1922
+ /**
1923
+ * Read the verified head, active messages, and (when present) one durable
1924
+ * WebSocket request settlement from the same locked transcript sample.
1925
+ * Tracking only the requested id keeps idempotency lookup heap-bounded.
1926
+ */
1927
+ export function readVerifiedWsTurnState(sessionId, requestId, options = {}) {
1928
+ const canonicalRequestId = normalizeWsTurnRequestId(requestId);
1929
+ const inputDigest =
1930
+ options.inputDigest == null
1931
+ ? null
1932
+ : normalizeWsTurnInputDigest(options.inputDigest);
1933
+ return readVerifiedProjection(
1934
+ sessionId,
1935
+ () => {
1936
+ const reducer = createWsTurnLifecycleReducer({
1937
+ requestId: canonicalRequestId,
1938
+ inputDigest,
1939
+ });
1940
+ return {
1941
+ accept(event) {
1942
+ reducer.accept(event);
1943
+ },
1944
+ finish(authority) {
1945
+ const state = reducer.finish(authority);
1946
+ return Object.freeze({
1947
+ sessionId,
1948
+ ...state,
1949
+ messages: Object.freeze([...authority.readMessages()]),
1950
+ });
1951
+ },
1952
+ };
1953
+ },
1954
+ options,
1955
+ );
1956
+ }
1957
+
1958
+ /**
1959
+ * Return the newest event matching a type/predicate using bounded reverse IO.
1960
+ * This is the recovery path for snapshots and ledgers: callers never need to
1961
+ * materialize a multi-gigabyte transcript merely to inspect its latest state.
1962
+ */
1963
+ export function findLatestEvent(sessionId, type, predicate = null) {
1964
+ if (isUnsafeSessionId(sessionId)) return null;
1965
+ const filePath = sessionPath(sessionId);
1966
+ if (!existsSync(filePath)) return null;
1967
+ const wanted = Array.isArray(type) ? new Set(type) : null;
1968
+ for (const { line } of iterateFileLinesReverseSync(filePath)) {
1969
+ let event;
1970
+ try {
1971
+ event = JSON.parse(line);
1972
+ } catch {
1973
+ continue;
1974
+ }
1975
+ const typeMatches = wanted
1976
+ ? wanted.has(event?.type)
1977
+ : type == null || event?.type === type;
1978
+ if (!typeMatches) continue;
1979
+ if (typeof predicate === "function" && !predicate(event)) continue;
1980
+ return event;
1981
+ }
1982
+ return null;
1983
+ }
1984
+
265
1985
  /**
266
1986
  * A replayable chat message must be a `{ role, content }` object — guard
267
1987
  * against a corrupt / partially-written / hand-edited event whose `data` is
@@ -272,32 +1992,35 @@ function isReplayableMessage(m) {
272
1992
  return Boolean(m) && typeof m === "object" && typeof m.role === "string";
273
1993
  }
274
1994
 
275
- export function rebuildMessages(sessionId) {
276
- const events = readEvents(sessionId);
277
- const messages = [];
278
- let lastCompactIndex = -1;
279
-
280
- // A `compact` event carries the pre-compaction snapshot in data.messages. A
281
- // malformed compact line (type present but data null / messages not an array)
282
- // must not crash the resume — skip it and look further back.
283
- for (let i = events.length - 1; i >= 0; i--) {
284
- const e = events[i];
285
- if (e && e.type === "compact" && Array.isArray(e.data?.messages)) {
286
- lastCompactIndex = i;
287
- break;
1995
+ function rebuildMessagesFromFile(
1996
+ filePath,
1997
+ options = {},
1998
+ decodeMessage = sanitizePersistedMessage,
1999
+ ) {
2000
+ const ioMetrics = options?.ioMetrics ?? null;
2001
+ // Scan newest-first and stop at the latest valid compact checkpoint. Memory
2002
+ // is therefore proportional to the active context suffix, not transcript
2003
+ // size. Without a compact event the full conversation is necessarily the
2004
+ // replay state, but the file itself is still never loaded as one giant string.
2005
+ const suffix = [];
2006
+ let checkpoint = [];
2007
+ for (const { line } of iterateFileLinesReverseSync(filePath, { ioMetrics })) {
2008
+ let event;
2009
+ try {
2010
+ event = JSON.parse(line);
2011
+ } catch {
2012
+ continue;
288
2013
  }
289
- }
290
-
291
- if (lastCompactIndex >= 0) {
292
- for (const m of events[lastCompactIndex].data.messages) {
293
- if (isReplayableMessage(m)) messages.push(m);
2014
+ if (
2015
+ (event?.type === "compact" ||
2016
+ event?.type === "checkpoint_timeline_commit") &&
2017
+ Array.isArray(event.data?.messages)
2018
+ ) {
2019
+ checkpoint = event.data.messages
2020
+ .map(decodeMessage)
2021
+ .filter(isReplayableMessage);
2022
+ break;
294
2023
  }
295
- }
296
-
297
- const startIndex = lastCompactIndex >= 0 ? lastCompactIndex + 1 : 0;
298
-
299
- for (let i = startIndex; i < events.length; i++) {
300
- const event = events[i];
301
2024
  if (
302
2025
  event &&
303
2026
  (event.type === "user_message" ||
@@ -305,11 +2028,33 @@ export function rebuildMessages(sessionId) {
305
2028
  event.type === "system") &&
306
2029
  isReplayableMessage(event.data)
307
2030
  ) {
308
- messages.push(event.data);
2031
+ suffix.push(decodeMessage(event.data));
2032
+ } else if (event?.type === WS_TURN_EVENT) {
2033
+ const turn = projectWsTurnMessages(event);
2034
+ if (turn) {
2035
+ // The file is scanned newest-first. Push the pair in reverse so the
2036
+ // final suffix.reverse() restores user → assistant atomically.
2037
+ suffix.push(turn.assistant, turn.user);
2038
+ }
309
2039
  }
310
2040
  }
2041
+ suffix.reverse();
2042
+ return [...checkpoint, ...suffix];
2043
+ }
2044
+
2045
+ function rebuildVerifiedMessagesFromFile(filePath, options = {}) {
2046
+ return rebuildMessagesFromFile(
2047
+ filePath,
2048
+ options,
2049
+ decodeVerifiedPersistedMessage,
2050
+ );
2051
+ }
311
2052
 
312
- return messages;
2053
+ export function rebuildMessages(sessionId, options = {}) {
2054
+ if (isUnsafeSessionId(sessionId)) return [];
2055
+ const filePath = sessionPath(sessionId);
2056
+ if (!existsSync(filePath)) return [];
2057
+ return rebuildMessagesFromFile(filePath, options);
313
2058
  }
314
2059
 
315
2060
  /** ISO string for a numeric ms timestamp, or "" when missing / non-finite /
@@ -325,51 +2070,79 @@ export function toIsoSafe(ts) {
325
2070
  return Number.isNaN(d.getTime()) ? "" : d.toISOString();
326
2071
  }
327
2072
 
2073
+ function rebuildSessionMetaUnlocked(dir, sessionId, filePath) {
2074
+ let meta = emptySessionMeta(sessionId);
2075
+ for (const { line } of iterateFileLinesSync(filePath)) {
2076
+ try {
2077
+ const event = JSON.parse(line);
2078
+ meta = applyEventToSessionMeta(meta, event, event?.hash);
2079
+ } catch {
2080
+ // The validator/repair path reports malformed records. The index remains
2081
+ // a best-effort projection over all intact events.
2082
+ }
2083
+ }
2084
+ return replaceSessionMeta(dir, meta);
2085
+ }
2086
+
2087
+ function rebuildSessionMeta(sessionId) {
2088
+ const dir = getSessionsDir();
2089
+ const filePath = sessionPath(sessionId);
2090
+ if (!existsSync(filePath)) return null;
2091
+ return withFileLock(
2092
+ filePath,
2093
+ () => rebuildSessionMetaUnlocked(dir, sessionId, filePath),
2094
+ {
2095
+ failIfUnavailable: true,
2096
+ timeoutMs: 30_000,
2097
+ retryMs: 1,
2098
+ maxRetryMs: 8,
2099
+ retryJitterMs: 4,
2100
+ },
2101
+ );
2102
+ }
2103
+
2104
+ /** Read canonical session metadata without loading the transcript body. */
2105
+ export function getJsonlSessionMetadata(sessionId) {
2106
+ if (isUnsafeSessionId(sessionId) || !sessionExists(sessionId)) return null;
2107
+ const dir = getSessionsDir();
2108
+ const meta = readSessionMeta(dir, sessionId) || rebuildSessionMeta(sessionId);
2109
+ return meta ? publicSessionMeta(meta) : null;
2110
+ }
2111
+
328
2112
  export function listJsonlSessions(options = {}) {
329
2113
  const dir = getSessionsDir();
330
2114
  if (!existsSync(dir)) return [];
331
2115
 
332
2116
  const limit = options.limit || 20;
333
- const files = readdirSync(dir)
334
- .filter((f) => f.endsWith(".jsonl"))
335
- .map((f) => {
336
- const id = basename(f, ".jsonl");
337
- const events = readEvents(id);
338
- const startEvent = events.find((e) => e.type === "session_start");
339
- const lastEvent = events[events.length - 1];
340
- const messageCount = events.filter(
341
- (e) => e.type === "user_message" || e.type === "assistant_message",
342
- ).length;
343
- // cc session rename appends session_rename events; the LAST one wins.
344
- let renamedTitle = null;
345
- for (const e of events) {
346
- if (e.type === "session_rename" && e.data?.title) {
347
- renamedTitle = e.data.title;
348
- }
349
- }
350
-
351
- return {
352
- id,
353
- title: renamedTitle || startEvent?.data?.title || "Untitled",
354
- provider: startEvent?.data?.provider || "",
355
- model: startEvent?.data?.model || "",
356
- message_count: messageCount,
357
- created_at: toIsoSafe(startEvent?.timestamp),
358
- updated_at: toIsoSafe(lastEvent?.timestamp),
359
- _lastTs: lastEvent?.timestamp || 0,
360
- _eventCount: events.length,
361
- };
362
- })
363
- .sort((a, b) => {
364
- // Primary: numeric timestamp descending
365
- if (b._lastTs !== a._lastTs) return b._lastTs - a._lastTs;
366
- // Tiebreak when ms collides: more events = more recent activity
367
- return b._eventCount - a._eventCount;
368
- })
369
- .slice(0, limit)
370
- .map(({ _lastTs, _eventCount, ...rest }) => rest);
2117
+ const sessionIds = listSessionIds();
2118
+ let indexed = listIndexedSessions(dir, {
2119
+ limit,
2120
+ hasSession: (id) => sessionExists(id),
2121
+ });
371
2122
 
372
- return files;
2123
+ // First use after upgrade (or a lost/corrupt rebuildable index): stream each
2124
+ // legacy transcript once to seed its small sidecar and activity record.
2125
+ const expected = Math.min(Math.max(0, Number(limit) || 0), sessionIds.length);
2126
+ if (indexed.length < expected) {
2127
+ const rebuilt = [];
2128
+ for (const id of sessionIds) {
2129
+ const meta = readSessionMeta(dir, id);
2130
+ rebuilt.push(meta || rebuildSessionMeta(id));
2131
+ }
2132
+ // Directory enumeration order is not activity order. Re-append the rebuilt
2133
+ // snapshots oldest-first so reverse journal reads preserve the transcript
2134
+ // timestamps even on the first post-upgrade listing.
2135
+ for (const meta of rebuilt
2136
+ .filter(Boolean)
2137
+ .sort((a, b) => (a.updated_at_ms || 0) - (b.updated_at_ms || 0))) {
2138
+ recordSessionActivity(dir, meta);
2139
+ }
2140
+ indexed = listIndexedSessions(dir, {
2141
+ limit,
2142
+ hasSession: (id) => sessionExists(id),
2143
+ });
2144
+ }
2145
+ return indexed;
373
2146
  }
374
2147
 
375
2148
  /**
@@ -387,6 +2160,41 @@ export function renameSession(sessionId, title) {
387
2160
  return { id: sessionId, title: normalized.slice(0, 200) };
388
2161
  }
389
2162
 
2163
+ /** Delete the canonical transcript under its writer lock and publish a
2164
+ * rebuildable tombstone so an already-waiting stale writer cannot resurrect
2165
+ * the session after the deletion commits. */
2166
+ export function deleteJsonlSession(sessionId) {
2167
+ if (isUnsafeSessionId(sessionId)) return false;
2168
+ const filePath = sessionPath(sessionId);
2169
+ if (!existsSync(filePath)) return false;
2170
+ return withFileLock(
2171
+ filePath,
2172
+ () => {
2173
+ if (!existsSync(filePath)) return false;
2174
+ rmSync(filePath, { force: true });
2175
+ try {
2176
+ recordSessionDeleted(getSessionsDir(), sessionId);
2177
+ } catch (error) {
2178
+ // writeMetaAtomic precedes the activity-journal append. If only the
2179
+ // rebuildable journal lock/release failed, the durable sidecar still
2180
+ // prevents a stale writer from resurrecting this deleted session.
2181
+ if (readSessionMeta(getSessionsDir(), sessionId)?.deleted !== true) {
2182
+ throw error;
2183
+ }
2184
+ }
2185
+ return true;
2186
+ },
2187
+ {
2188
+ failIfUnavailable: true,
2189
+ timeoutMs: 30_000,
2190
+ retryMs: 1,
2191
+ maxRetryMs: 8,
2192
+ retryJitterMs: 4,
2193
+ yieldAfterReleaseMs: 2,
2194
+ },
2195
+ );
2196
+ }
2197
+
390
2198
  /**
391
2199
  * Prune old sessions (gap-analysis 2026-07-11 P1 "保留期限"): delete session
392
2200
  * transcripts whose LAST activity is older than `olderThanDays`, always
@@ -419,9 +2227,7 @@ export function pruneJsonlSessions(options = {}) {
419
2227
  continue;
420
2228
  }
421
2229
  try {
422
- rmSync(sessionPath(s.id), { force: true });
423
- _chainTailCache.delete(s.id);
424
- deleted.push(s.id);
2230
+ if (deleteJsonlSession(s.id)) deleted.push(s.id);
425
2231
  } catch {
426
2232
  /* per-file failures never abort the sweep */
427
2233
  }
@@ -434,31 +2240,405 @@ export function pruneJsonlSessions(options = {}) {
434
2240
  };
435
2241
  }
436
2242
 
437
- export function forkSession(sourceId) {
438
- const events = readEvents(sourceId);
439
- if (events.length === 0) return null;
440
-
441
- const newId = `session-${Date.now()}-${createHash("sha256").update(Math.random().toString()).digest("hex").slice(0, 6)}`;
442
- const filePath = sessionPath(newId);
2243
+ function compareFilePrefix(candidatePath, fullPath) {
2244
+ const candidateFd = openSync(candidatePath, "r");
2245
+ const fullFd = openSync(fullPath, "r");
2246
+ try {
2247
+ const candidateSize = fstatSync(candidateFd).size;
2248
+ const fullSize = fstatSync(fullFd).size;
2249
+ if (candidateSize > fullSize) {
2250
+ return { matches: false, candidateSize, fullSize };
2251
+ }
2252
+ const candidateBuffer = Buffer.allocUnsafe(64 * 1024);
2253
+ const fullBuffer = Buffer.allocUnsafe(64 * 1024);
2254
+ let offset = 0;
2255
+ while (offset < candidateSize) {
2256
+ const length = Math.min(candidateBuffer.length, candidateSize - offset);
2257
+ const candidateRead = readSync(
2258
+ candidateFd,
2259
+ candidateBuffer,
2260
+ 0,
2261
+ length,
2262
+ offset,
2263
+ );
2264
+ const fullRead = readSync(fullFd, fullBuffer, 0, length, offset);
2265
+ if (
2266
+ candidateRead !== length ||
2267
+ fullRead !== length ||
2268
+ !candidateBuffer
2269
+ .subarray(0, length)
2270
+ .equals(fullBuffer.subarray(0, length))
2271
+ ) {
2272
+ return { matches: false, candidateSize, fullSize };
2273
+ }
2274
+ offset += length;
2275
+ }
2276
+ return { matches: true, candidateSize, fullSize };
2277
+ } finally {
2278
+ closeSync(candidateFd);
2279
+ closeSync(fullFd);
2280
+ }
2281
+ }
443
2282
 
444
- for (const event of events) {
445
- const line = JSON.stringify(event) + "\n";
446
- appendFileSync(filePath, line, "utf-8");
2283
+ const SESSION_FORK_AUTHORITY_SCHEMA =
2284
+ "chainlesschain.session-fork-authority/v1";
2285
+
2286
+ function normalizeForkRequestId(options) {
2287
+ const requestId = options?.requestId ?? "default";
2288
+ if (
2289
+ typeof requestId !== "string" ||
2290
+ requestId.length === 0 ||
2291
+ Buffer.byteLength(requestId, "utf8") > 256
2292
+ ) {
2293
+ throw new TypeError(
2294
+ "fork requestId must be a non-empty string <= 256 bytes",
2295
+ );
447
2296
  }
2297
+ return requestId;
2298
+ }
448
2299
 
449
- appendEvent(newId, "system", {
450
- role: "system",
451
- content: `[Forked from session ${sourceId}]`,
452
- });
2300
+ /**
2301
+ * Copy one verified source revision into an idempotent successor. The target is
2302
+ * bound to (sourceId, requestId), while its lineage record commits the source
2303
+ * revision selected by the first completed attempt. Callers that intentionally
2304
+ * need separate forks must pass separate requestId values.
2305
+ */
2306
+ export function forkSession(sourceId, options = {}) {
2307
+ if (isUnsafeSessionId(sourceId)) return null;
2308
+ const requestId = normalizeForkRequestId(options);
2309
+ const sourcePath = sessionPath(sourceId);
2310
+ if (!existsSync(sourcePath)) return null;
2311
+
2312
+ return withFileLock(
2313
+ sourcePath,
2314
+ () => {
2315
+ if (!existsSync(sourcePath)) return null;
2316
+ const sourceVerification = verifyTranscriptFile(sourcePath);
2317
+ assertVerifiedTranscriptAnchor(sourceId, sourceVerification);
2318
+ const forkIdentity = JSON.stringify({
2319
+ schemaVersion: 1,
2320
+ sourceSessionId: sourceId,
2321
+ requestId,
2322
+ });
2323
+ const forkDigest = createHash("sha256")
2324
+ .update(forkIdentity, "utf8")
2325
+ .digest("hex");
2326
+ const requestDigest = `sha256:${forkDigest}`;
2327
+ const newId = `session-fork-${forkDigest.slice(0, 32)}`;
2328
+ const filePath = sessionPath(newId);
2329
+ const sessionsDir = getSessionsDir();
2330
+ const pendingPath = join(sessionsDir, `${newId}.fork.pending`);
2331
+ const persistedData = encodeEventMessageProvenance(
2332
+ "system",
2333
+ markDurableSystemMessage(
2334
+ {
2335
+ role: "system",
2336
+ content: `[Forked from session ${sourceId}]`,
2337
+ },
2338
+ DURABLE_SYSTEM_MESSAGE_KINDS.FORK_LINEAGE,
2339
+ ),
2340
+ );
453
2341
 
454
- return newId;
2342
+ return withFileLock(
2343
+ filePath,
2344
+ () => {
2345
+ let targetMeta = readSessionMeta(sessionsDir, newId);
2346
+ const finalExists = existsSync(filePath);
2347
+ const pendingExists = existsSync(pendingPath);
2348
+ if (finalExists && pendingExists) {
2349
+ throw unverifiedTranscriptError(
2350
+ newId,
2351
+ verifyTranscriptFile(filePath),
2352
+ );
2353
+ }
2354
+ if (!finalExists && targetMeta !== null) {
2355
+ throw unverifiedTranscriptError(newId, {
2356
+ status: "not-found",
2357
+ chainedEvents: 0,
2358
+ malformedLines: 0,
2359
+ truncatedTail: false,
2360
+ lastHash: null,
2361
+ });
2362
+ }
2363
+ let workingPath = finalExists ? filePath : pendingPath;
2364
+ if (!finalExists && !pendingExists) {
2365
+ if (targetMeta !== null) {
2366
+ throw unverifiedTranscriptError(newId, {
2367
+ status: "not-found",
2368
+ chainedEvents: 0,
2369
+ malformedLines: 0,
2370
+ truncatedTail: false,
2371
+ lastHash: null,
2372
+ });
2373
+ }
2374
+ copyFileSync(sourcePath, workingPath, fsConstants.COPYFILE_EXCL);
2375
+ ensurePrivateFile(workingPath);
2376
+ runSessionScaleFaultHook("afterForkCopy", {
2377
+ sourceId,
2378
+ sessionId: newId,
2379
+ filePath: workingPath,
2380
+ });
2381
+ } else {
2382
+ const prefix = compareFilePrefix(workingPath, sourcePath);
2383
+ if (prefix.matches && prefix.candidateSize < prefix.fullSize) {
2384
+ if (targetMeta !== null) {
2385
+ throw unverifiedTranscriptError(
2386
+ newId,
2387
+ verifyTranscriptFile(workingPath),
2388
+ );
2389
+ }
2390
+ // copyFileSync can leave an exact byte prefix after a hard exit.
2391
+ // The deterministic target lock and prefix proof make replacing
2392
+ // only that unpublished target safe and crash-resumable.
2393
+ rmSync(workingPath, { force: true });
2394
+ copyFileSync(sourcePath, workingPath, fsConstants.COPYFILE_EXCL);
2395
+ ensurePrivateFile(workingPath);
2396
+ runSessionScaleFaultHook("afterForkCopy", {
2397
+ sourceId,
2398
+ sessionId: newId,
2399
+ filePath: workingPath,
2400
+ });
2401
+ }
2402
+ }
2403
+
2404
+ const inspectTarget = () => {
2405
+ let sourceBoundaryHash = null;
2406
+ let lineageEvent = null;
2407
+ let lineageEventIndex = null;
2408
+ let lineageAuthority = null;
2409
+ let matchingLineageEvents = 0;
2410
+ let eventIndex = 0;
2411
+ const verification = verifyTranscriptFile(workingPath, {
2412
+ onVerifiedEvent(event) {
2413
+ eventIndex += 1;
2414
+ if (eventIndex === sourceVerification.chainedEvents) {
2415
+ sourceBoundaryHash = event.hash;
2416
+ }
2417
+ const authority = event?.data?.[SESSION_FORK_AUTHORITY_FIELD];
2418
+ if (
2419
+ event?.type === "system" &&
2420
+ authority?.schema === SESSION_FORK_AUTHORITY_SCHEMA &&
2421
+ authority?.requestDigest === requestDigest &&
2422
+ authority?.sourceSessionId === sourceId
2423
+ ) {
2424
+ matchingLineageEvents += 1;
2425
+ lineageEvent = event;
2426
+ lineageEventIndex = eventIndex;
2427
+ lineageAuthority = authority;
2428
+ }
2429
+ },
2430
+ });
2431
+ return {
2432
+ verification,
2433
+ sourceBoundaryHash,
2434
+ lineageEvent,
2435
+ lineageEventIndex,
2436
+ lineageAuthority,
2437
+ matchingLineageEvents,
2438
+ };
2439
+ };
2440
+
2441
+ let target = inspectTarget();
2442
+ if (
2443
+ target.sourceBoundaryHash === sourceVerification.lastHash &&
2444
+ target.verification.chainedEvents ===
2445
+ sourceVerification.chainedEvents &&
2446
+ target.verification.truncatedTail &&
2447
+ targetMeta === null
2448
+ ) {
2449
+ // A hard exit can cut the one lineage append. Discard only that
2450
+ // unanchored physical tail, then prove the copied source again.
2451
+ inspectPhysicalTail(workingPath);
2452
+ target = inspectTarget();
2453
+ }
2454
+
2455
+ const cleanTarget =
2456
+ target.verification.status === TRANSCRIPT_CHAIN_STATUS.VERIFIED &&
2457
+ target.verification.malformedLines === 0 &&
2458
+ !target.verification.truncatedTail;
2459
+ const exactSourceCopy =
2460
+ cleanTarget &&
2461
+ target.sourceBoundaryHash === sourceVerification.lastHash &&
2462
+ target.verification.chainedEvents ===
2463
+ sourceVerification.chainedEvents;
2464
+
2465
+ if (exactSourceCopy) {
2466
+ if (targetMeta !== null) {
2467
+ throw unverifiedTranscriptError(newId, target.verification);
2468
+ }
2469
+ const { prevHash } = _resolveChainTail(workingPath);
2470
+ if (prevHash !== sourceVerification.lastHash) {
2471
+ const error = new Error(
2472
+ `Fork source changed while copying: ${sourceId}`,
2473
+ );
2474
+ error.code = "SESSION_REVISION_STALE";
2475
+ throw error;
2476
+ }
2477
+ const forkAuthority = {
2478
+ schema: SESSION_FORK_AUTHORITY_SCHEMA,
2479
+ requestDigest,
2480
+ sourceSessionId: sourceId,
2481
+ sourceHeadHash: sourceVerification.lastHash,
2482
+ sourceEventCount: sourceVerification.chainedEvents,
2483
+ };
2484
+ const core = {
2485
+ type: "system",
2486
+ timestamp: Date.now(),
2487
+ data: {
2488
+ ...persistedData,
2489
+ [SESSION_FORK_AUTHORITY_FIELD]: forkAuthority,
2490
+ },
2491
+ };
2492
+ const hash = computeEventHash(prevHash, core);
2493
+ appendFileSync(
2494
+ workingPath,
2495
+ `${JSON.stringify({ ...core, prevHash, hash })}\n`,
2496
+ { encoding: "utf8", mode: 0o600 },
2497
+ );
2498
+ ensurePrivateFile(workingPath);
2499
+ runSessionScaleFaultHook("afterForkLineage", {
2500
+ sourceId,
2501
+ sessionId: newId,
2502
+ filePath: workingPath,
2503
+ event: { ...core, prevHash, hash },
2504
+ });
2505
+ target = inspectTarget();
2506
+ targetMeta = readSessionMeta(sessionsDir, newId);
2507
+ }
2508
+
2509
+ const authority = target.lineageAuthority;
2510
+ const authorityEventCount = Number(authority?.sourceEventCount);
2511
+ const creationEventCount = authorityEventCount + 1;
2512
+ const expectedAuthority = {
2513
+ schema: SESSION_FORK_AUTHORITY_SCHEMA,
2514
+ requestDigest,
2515
+ sourceSessionId: sourceId,
2516
+ sourceHeadHash: authority?.sourceHeadHash,
2517
+ sourceEventCount: authorityEventCount,
2518
+ };
2519
+ const lineageMatches =
2520
+ target.matchingLineageEvents === 1 &&
2521
+ target.lineageEvent?.type === "system" &&
2522
+ Number.isSafeInteger(authorityEventCount) &&
2523
+ authorityEventCount > 0 &&
2524
+ typeof authority?.sourceHeadHash === "string" &&
2525
+ target.lineageEventIndex === creationEventCount &&
2526
+ target.lineageEvent?.prevHash === authority.sourceHeadHash &&
2527
+ JSON.stringify(target.lineageEvent?.data) ===
2528
+ JSON.stringify({
2529
+ ...persistedData,
2530
+ [SESSION_FORK_AUTHORITY_FIELD]: expectedAuthority,
2531
+ });
2532
+ if (
2533
+ target.verification.status !== TRANSCRIPT_CHAIN_STATUS.VERIFIED ||
2534
+ target.verification.chainedEvents < creationEventCount ||
2535
+ !lineageMatches ||
2536
+ target.verification.malformedLines > 0 ||
2537
+ target.verification.truncatedTail
2538
+ ) {
2539
+ throw unverifiedTranscriptError(newId, target.verification);
2540
+ }
2541
+
2542
+ const anchorMatchesTarget =
2543
+ targetMeta?.deleted !== true &&
2544
+ targetMeta?.last_hash === target.verification.lastHash &&
2545
+ Number(targetMeta?.event_count) ===
2546
+ target.verification.chainedEvents;
2547
+ if (anchorMatchesTarget) {
2548
+ const activity = readLatestSessionActivity(sessionsDir, newId);
2549
+ if (
2550
+ activity?.deleted === true ||
2551
+ activity?.last_hash !== targetMeta.last_hash ||
2552
+ Number(activity?.event_count) !== Number(targetMeta.event_count)
2553
+ ) {
2554
+ recordSessionActivity(sessionsDir, targetMeta);
2555
+ }
2556
+ return newId;
2557
+ }
2558
+
2559
+ // A completed or progressed successor with a non-matching anchor is a
2560
+ // rollback/conflict, never a crash prefix that this request may bless.
2561
+ if (
2562
+ targetMeta !== null ||
2563
+ target.verification.chainedEvents > creationEventCount
2564
+ ) {
2565
+ throw unverifiedTranscriptError(newId, target.verification);
2566
+ }
2567
+
2568
+ let sourceAuthorityHash = null;
2569
+ if (authorityEventCount === sourceVerification.chainedEvents) {
2570
+ sourceAuthorityHash = sourceVerification.lastHash;
2571
+ } else if (authorityEventCount < sourceVerification.chainedEvents) {
2572
+ let index = 0;
2573
+ const authorityVerification = verifyTranscriptFile(sourcePath, {
2574
+ onVerifiedEvent(event) {
2575
+ index += 1;
2576
+ if (index === authorityEventCount) {
2577
+ sourceAuthorityHash = event.hash;
2578
+ }
2579
+ },
2580
+ });
2581
+ assertVerifiedTranscriptAnchor(sourceId, authorityVerification);
2582
+ }
2583
+ if (sourceAuthorityHash !== authority.sourceHeadHash) {
2584
+ const error = new Error(
2585
+ `Fork source revision no longer contains the claimed authority: ${sourceId}`,
2586
+ );
2587
+ error.code = "SESSION_REVISION_STALE";
2588
+ throw error;
2589
+ }
2590
+
2591
+ if (workingPath === pendingPath) {
2592
+ if (existsSync(filePath)) {
2593
+ throw unverifiedTranscriptError(
2594
+ newId,
2595
+ verifyTranscriptFile(filePath),
2596
+ );
2597
+ }
2598
+ renameSync(pendingPath, filePath);
2599
+ workingPath = filePath;
2600
+ ensurePrivateFile(workingPath);
2601
+ runSessionScaleFaultHook("afterForkPublish", {
2602
+ sourceId,
2603
+ sessionId: newId,
2604
+ filePath: workingPath,
2605
+ });
2606
+ }
2607
+
2608
+ rebuildSessionMetaUnlocked(sessionsDir, newId, filePath);
2609
+ runSessionScaleFaultHook("afterForkMeta", {
2610
+ sourceId,
2611
+ sessionId: newId,
2612
+ filePath,
2613
+ });
2614
+ assertVerifiedTranscriptAnchor(newId, target.verification);
2615
+ return newId;
2616
+ },
2617
+ {
2618
+ failIfUnavailable: true,
2619
+ timeoutMs: 30_000,
2620
+ retryMs: 1,
2621
+ maxRetryMs: 8,
2622
+ retryJitterMs: 4,
2623
+ },
2624
+ );
2625
+ },
2626
+ {
2627
+ failIfUnavailable: true,
2628
+ timeoutMs: 30_000,
2629
+ retryMs: 1,
2630
+ maxRetryMs: 8,
2631
+ retryJitterMs: 4,
2632
+ yieldAfterReleaseMs: 2,
2633
+ },
2634
+ );
455
2635
  }
456
2636
 
457
2637
  /**
458
2638
  * Create an independent BRANCH session ("从这里分支" — P0-3's fourth restore
459
2639
  * action) that keeps a parent's conversation up to a chosen turn and diverges
460
- * from there. Unlike forkSession() (whole-session copy, random id, no lineage),
461
- * this writes ONLY the caller-supplied pre-branch messages under a deterministic
2640
+ * from there. Unlike forkSession() (a whole-session copy with revision-scoped
2641
+ * request identity), this writes ONLY the caller-supplied pre-branch messages
462
2642
  * id (see deriveBranchSessionId) and records parent lineage — so the origin
463
2643
  * session is never touched (preservesParent) and a replayed branch request
464
2644
  * resolves to the SAME file instead of a duplicate (idempotent).
@@ -483,35 +2663,214 @@ export function createBranchSession({
483
2663
  `unsafe branch session id: ${String(branchSessionId).slice(0, 60)}`,
484
2664
  );
485
2665
  }
486
- // Idempotent: a replayed branch request resolves to the existing branch
487
- // rather than doubling it (matches deriveBranchId's determinism).
488
- if (existsSync(sessionPath(branchSessionId))) {
489
- return { branchSessionId, created: false, messages: 0 };
490
- }
491
-
492
- startSession(branchSessionId, {
493
- title: meta.title || `Branch of ${parentSessionId ?? "session"}`,
494
- provider: meta.provider || "",
495
- model: meta.model || "",
496
- });
497
- appendEvent(branchSessionId, "session_branch", {
498
- parentSessionId: parentSessionId == null ? null : String(parentSessionId),
499
- parentTurnId: parentTurnId == null ? null : String(parentTurnId),
2666
+ // Validate and clone the complete message projection before creating the
2667
+ // deterministic branch file. A validation failure must leave no partial
2668
+ // session that would make a later, valid retry look idempotently complete.
2669
+ const canonicalMessages = projectCanonicalResumeMessages(messages, {
2670
+ strict: true,
500
2671
  });
501
-
2672
+ if (
2673
+ (parentSessionId !== null && typeof parentSessionId !== "string") ||
2674
+ (parentTurnId !== null && typeof parentTurnId !== "string")
2675
+ ) {
2676
+ throw new TypeError("Branch lineage ids must be strings or null");
2677
+ }
2678
+ const branchParentSessionId = parentSessionId;
2679
+ const branchParentTurnId = parentTurnId;
2680
+ const cleanMeta = sanitizePersistedMessage(meta);
2681
+ if (!cleanMeta || typeof cleanMeta !== "object") {
2682
+ throw new TypeError("Branch metadata must contain JSON-safe data");
2683
+ }
2684
+ const startData = {
2685
+ title:
2686
+ typeof cleanMeta.title === "string" && cleanMeta.title
2687
+ ? cleanMeta.title
2688
+ : `Branch of ${branchParentSessionId ?? "session"}`,
2689
+ provider: typeof cleanMeta.provider === "string" ? cleanMeta.provider : "",
2690
+ model: typeof cleanMeta.model === "string" ? cleanMeta.model : "",
2691
+ };
2692
+ const plannedEvents = [
2693
+ { type: "session_start", data: startData },
2694
+ {
2695
+ type: "session_branch",
2696
+ data: {
2697
+ parentSessionId: branchParentSessionId,
2698
+ parentTurnId: branchParentTurnId,
2699
+ },
2700
+ },
2701
+ ];
502
2702
  let count = 0;
503
- for (const m of Array.isArray(messages) ? messages : []) {
504
- if (!m || m.role == null) continue;
2703
+ for (const m of canonicalMessages) {
505
2704
  if (m.role === "user") {
506
- appendUserMessage(branchSessionId, m.content);
2705
+ plannedEvents.push({
2706
+ type: "user_message",
2707
+ data: { role: "user", content: m.content },
2708
+ });
507
2709
  count += 1;
508
2710
  } else if (m.role === "assistant") {
509
- appendAssistantMessage(branchSessionId, m.content);
2711
+ plannedEvents.push({
2712
+ type: "assistant_message",
2713
+ data: { role: "assistant", content: m.content },
2714
+ });
2715
+ count += 1;
2716
+ } else if (m.role === "system") {
2717
+ plannedEvents.push({
2718
+ type: "system",
2719
+ data: encodePersistedMessage(m),
2720
+ });
510
2721
  count += 1;
511
2722
  }
512
- // system prompt + tool scaffolding are re-established on resume; skip here.
2723
+ // Host prompts and tool scaffolding are re-established on resume. The
2724
+ // canonical projection above retains only explicitly durable systems.
513
2725
  }
514
- return { branchSessionId, created: true, messages: count };
2726
+ const inputDigest = `sha256:${createHash("sha256")
2727
+ .update(JSON.stringify(plannedEvents), "utf8")
2728
+ .digest("hex")}`;
2729
+ plannedEvents.push({
2730
+ type: "session_branch_complete",
2731
+ data: {
2732
+ schemaVersion: 1,
2733
+ inputDigest,
2734
+ messageCount: count,
2735
+ },
2736
+ });
2737
+
2738
+ const filePath = sessionPath(branchSessionId);
2739
+ const sessionsDir = getSessionsDir();
2740
+ return withFileLock(
2741
+ filePath,
2742
+ () => {
2743
+ if (existsSync(filePath)) inspectPhysicalTail(filePath);
2744
+ let verification = existsSync(filePath)
2745
+ ? verifyTranscriptFile(filePath)
2746
+ : {
2747
+ status: TRANSCRIPT_CHAIN_STATUS.EMPTY,
2748
+ chainedEvents: 0,
2749
+ malformedLines: 0,
2750
+ truncatedTail: false,
2751
+ lastHash: null,
2752
+ };
2753
+ if (
2754
+ verification.status !== TRANSCRIPT_CHAIN_STATUS.EMPTY &&
2755
+ verification.status !== TRANSCRIPT_CHAIN_STATUS.VERIFIED
2756
+ ) {
2757
+ throw unverifiedTranscriptError(branchSessionId, verification);
2758
+ }
2759
+ const existingEvents = existsSync(filePath)
2760
+ ? readEvents(branchSessionId)
2761
+ : [];
2762
+ const prefixMatches = existingEvents
2763
+ .slice(0, Math.min(existingEvents.length, plannedEvents.length))
2764
+ .every(
2765
+ (event, index) =>
2766
+ event.type === plannedEvents[index].type &&
2767
+ JSON.stringify(event.data) ===
2768
+ JSON.stringify(plannedEvents[index].data),
2769
+ );
2770
+ if (!prefixMatches) {
2771
+ const error = new Error(
2772
+ `Branch session conflicts with its deterministic input: ${branchSessionId}`,
2773
+ );
2774
+ error.code = "SESSION_BRANCH_CONFLICT";
2775
+ throw error;
2776
+ }
2777
+
2778
+ // Once the exact completion marker is anchored, later branch turns are
2779
+ // outside the idempotent creation transaction. A replay resolves to that
2780
+ // progressed branch, but an unanchored suffix is never re-blessed.
2781
+ if (existingEvents.length > plannedEvents.length) {
2782
+ assertVerifiedTranscriptAnchor(branchSessionId, verification);
2783
+ return { branchSessionId, created: false, messages: 0 };
2784
+ }
2785
+
2786
+ // A crashed writer can leave a hash-valid planned prefix ahead of its
2787
+ // sidecar. Only advance an existing anchor along that exact prefix. Never
2788
+ // lower or replace a higher/different anchor: doing so would legitimize a
2789
+ // truncated deterministic branch and permanently discard later turns.
2790
+ const anchoredMeta = readSessionMeta(sessionsDir, branchSessionId);
2791
+ const anchoredCount = Number(anchoredMeta?.event_count);
2792
+ const anchorMatchesCurrent =
2793
+ anchoredMeta?.deleted !== true &&
2794
+ anchoredMeta?.last_hash === verification.lastHash &&
2795
+ anchoredCount === verification.chainedEvents;
2796
+ const anchorIsStrictPriorPrefix =
2797
+ anchoredMeta?.deleted !== true &&
2798
+ Number.isSafeInteger(anchoredCount) &&
2799
+ anchoredCount >= 0 &&
2800
+ anchoredCount < verification.chainedEvents &&
2801
+ verification.chainedEvents === existingEvents.length &&
2802
+ (anchoredCount === 0
2803
+ ? anchoredMeta?.last_hash === null
2804
+ : existingEvents[anchoredCount - 1]?.hash ===
2805
+ anchoredMeta?.last_hash);
2806
+ if (
2807
+ anchoredMeta !== null &&
2808
+ !anchorMatchesCurrent &&
2809
+ !anchorIsStrictPriorPrefix
2810
+ ) {
2811
+ throw unverifiedTranscriptError(branchSessionId, verification);
2812
+ }
2813
+ if (existsSync(filePath) && !anchorMatchesCurrent) {
2814
+ rebuildSessionMetaUnlocked(sessionsDir, branchSessionId, filePath);
2815
+ }
2816
+ if (existingEvents.length > 0) {
2817
+ assertVerifiedTranscriptAnchor(branchSessionId, verification);
2818
+ }
2819
+ if (existingEvents.length === plannedEvents.length) {
2820
+ return { branchSessionId, created: false, messages: 0 };
2821
+ }
2822
+
2823
+ let prevHash = verification.lastHash;
2824
+ for (
2825
+ let index = existingEvents.length;
2826
+ index < plannedEvents.length;
2827
+ index += 1
2828
+ ) {
2829
+ const planned = plannedEvents[index];
2830
+ const core = {
2831
+ type: planned.type,
2832
+ timestamp: Date.now(),
2833
+ data: planned.data,
2834
+ };
2835
+ const hash = computeEventHash(prevHash, core);
2836
+ const event = { ...core, prevHash, hash };
2837
+ appendFileSync(filePath, `${JSON.stringify(event)}\n`, {
2838
+ encoding: "utf8",
2839
+ mode: 0o600,
2840
+ });
2841
+ ensurePrivateFile(filePath);
2842
+ runSessionScaleFaultHook("afterTranscriptAppend", {
2843
+ sessionId: branchSessionId,
2844
+ type: planned.type,
2845
+ event,
2846
+ filePath,
2847
+ });
2848
+ prevHash = hash;
2849
+ }
2850
+
2851
+ verification = verifyTranscriptFile(filePath);
2852
+ if (
2853
+ verification.status !== TRANSCRIPT_CHAIN_STATUS.VERIFIED ||
2854
+ verification.chainedEvents !== plannedEvents.length ||
2855
+ verification.lastHash !== prevHash ||
2856
+ verification.malformedLines > 0 ||
2857
+ verification.truncatedTail
2858
+ ) {
2859
+ throw unverifiedTranscriptError(branchSessionId, verification);
2860
+ }
2861
+ rebuildSessionMetaUnlocked(sessionsDir, branchSessionId, filePath);
2862
+ assertVerifiedTranscriptAnchor(branchSessionId, verification);
2863
+ return { branchSessionId, created: true, messages: count };
2864
+ },
2865
+ {
2866
+ failIfUnavailable: true,
2867
+ timeoutMs: 30_000,
2868
+ retryMs: 1,
2869
+ maxRetryMs: 8,
2870
+ retryJitterMs: 4,
2871
+ yieldAfterReleaseMs: 2,
2872
+ },
2873
+ );
515
2874
  }
516
2875
 
517
2876
  export function sessionExists(sessionId) {
@@ -618,30 +2977,48 @@ export function validateJsonlSession(sessionId) {
618
2977
  };
619
2978
  }
620
2979
 
621
- const lines = readFileSync(filePath, "utf-8").split("\n");
622
2980
  let malformedLines = 0;
623
- const events = [];
624
-
625
- for (const line of lines) {
626
- if (!line.trim()) continue;
2981
+ let eventCount = 0;
2982
+ let messageCount = 0;
2983
+ let invalidWsTurns = 0;
2984
+ let invalidWsClaims = 0;
2985
+ let hasStartEvent = false;
2986
+ for (const { line } of iterateFileLinesSync(filePath)) {
627
2987
  try {
628
- events.push(JSON.parse(line));
2988
+ const event = JSON.parse(line);
2989
+ eventCount += 1;
2990
+ if (event?.type === "session_start") hasStartEvent = true;
2991
+ if (
2992
+ event?.type === "user_message" ||
2993
+ event?.type === "assistant_message"
2994
+ ) {
2995
+ messageCount += 1;
2996
+ } else if (event?.type === WS_TURN_EVENT) {
2997
+ const settlement = projectWsTurnSettlement(event);
2998
+ if (!settlement) invalidWsTurns += 1;
2999
+ else if (settlement.outcome === "completed") messageCount += 2;
3000
+ } else if (
3001
+ event?.type === WS_TURN_CLAIM_EVENT &&
3002
+ !projectWsTurnClaim(event)
3003
+ ) {
3004
+ invalidWsClaims += 1;
3005
+ }
629
3006
  } catch {
630
3007
  malformedLines++;
631
3008
  }
632
3009
  }
633
3010
 
634
- const hasStartEvent = events.some((event) => event.type === "session_start");
635
- const messageCount = events.filter(
636
- (event) =>
637
- event.type === "user_message" || event.type === "assistant_message",
638
- ).length;
639
-
640
3011
  return {
641
3012
  sessionId,
642
- valid: malformedLines === 0 && hasStartEvent,
3013
+ valid:
3014
+ malformedLines === 0 &&
3015
+ invalidWsTurns === 0 &&
3016
+ invalidWsClaims === 0 &&
3017
+ hasStartEvent,
643
3018
  malformedLines,
644
- eventCount: events.length,
3019
+ invalidWsTurns,
3020
+ invalidWsClaims,
3021
+ eventCount,
645
3022
  messageCount,
646
3023
  hasStartEvent,
647
3024
  };
@@ -709,10 +3086,17 @@ function performLegacySessionMigration(sourcePath, options) {
709
3086
  appendLegacyMessage(sessionId, message);
710
3087
  }
711
3088
  if (legacy.summary) {
712
- appendEvent(sessionId, "system", {
713
- role: "system",
714
- content: `[Migrated Summary]\n${legacy.summary}`,
715
- });
3089
+ appendEvent(
3090
+ sessionId,
3091
+ "system",
3092
+ markDurableSystemMessage(
3093
+ {
3094
+ role: "system",
3095
+ content: `[Migrated Summary]\n${legacy.summary}`,
3096
+ },
3097
+ DURABLE_SYSTEM_MESSAGE_KINDS.MIGRATION_SUMMARY,
3098
+ ),
3099
+ );
716
3100
  }
717
3101
 
718
3102
  const validation = validateJsonlSession(sessionId);