chainlesschain 0.162.197 → 0.162.200

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 (233) hide show
  1. package/bin/chainlesschain.js +14 -6
  2. package/package.json +4 -1
  3. package/scripts/command-lifecycle-report.mjs +94 -0
  4. package/src/assets/web-panel/assets/{AIOps-D3Gu1a5d.js → AIOps-DcQ0bWKy.js} +1 -1
  5. package/src/assets/web-panel/assets/{ActionButton-DrwAoKaz.js → ActionButton-GecwfFPt.js} +1 -1
  6. package/src/assets/web-panel/assets/{Analytics-C7Q-gd_3.js → Analytics-CD6iSt9v.js} +3 -3
  7. package/src/assets/web-panel/assets/{AppLayout-DgBtae-n.js → AppLayout-CDEtuEYf.js} +5 -5
  8. package/src/assets/web-panel/assets/{Artifacts-pQ6LcA-W.js → Artifacts-DS3jAjeW.js} +1 -1
  9. package/src/assets/web-panel/assets/{Audit-DK61tocB.js → Audit-CcSJA048.js} +1 -1
  10. package/src/assets/web-panel/assets/{BackgroundAgents-BOKgDFRh.js → BackgroundAgents-DRo9Morb.js} +1 -1
  11. package/src/assets/web-panel/assets/{Backup-Bo9G4vTP.js → Backup-Vw5MrWft.js} +1 -1
  12. package/src/assets/web-panel/assets/{BaseInput-Do_dE9_v.js → BaseInput-DNHIu823.js} +1 -1
  13. package/src/assets/web-panel/assets/{Chat-COVvZzOL.js → Chat-DvAsO8-b.js} +4 -4
  14. package/src/assets/web-panel/assets/ChatBubbleRenderer-9aDuObay.js +1 -0
  15. package/src/assets/web-panel/assets/{Checkbox-Z9MJMolt.js → Checkbox-CKCa7tvn.js} +1 -1
  16. package/src/assets/web-panel/assets/{Codegen-BVzWKVbf.js → Codegen-CIFvZdaT.js} +1 -1
  17. package/src/assets/web-panel/assets/{Col-C8MnYcbT.js → Col-C79yz7bK.js} +1 -1
  18. package/src/assets/web-panel/assets/{Community-DQxqw15l.js → Community-DVvZskLJ.js} +1 -1
  19. package/src/assets/web-panel/assets/{Compact-BAUmrh3y.js → Compact-oaew61hV.js} +1 -1
  20. package/src/assets/web-panel/assets/{Compliance-jPAkQ3zK.js → Compliance-DqA7wmRx.js} +1 -1
  21. package/src/assets/web-panel/assets/{Cowork-BBQyxbh8.js → Cowork-B-qGadxa.js} +3 -3
  22. package/src/assets/web-panel/assets/{Cron-W5koq8Z5.js → Cron-CUlCl4sS.js} +2 -2
  23. package/src/assets/web-panel/assets/{Crosschain-BQhnu5hS.js → Crosschain-CNOlr9DU.js} +1 -1
  24. package/src/assets/web-panel/assets/{DID-DD7PlI18.js → DID-DJqFCT_3.js} +2 -2
  25. package/src/assets/web-panel/assets/{Dashboard-w_ILuwKI.js → Dashboard-CznGpphf.js} +2 -2
  26. package/src/assets/web-panel/assets/{Dropdown-qiDUAyAo.js → Dropdown-_cJw218E.js} +1 -1
  27. package/src/assets/web-panel/assets/{EmailListRenderer-PDeBcI5H.js → EmailListRenderer-Dr-1BZ9C.js} +1 -1
  28. package/src/assets/web-panel/assets/{FamilyGuardDashboard-SuArscRM.js → FamilyGuardDashboard-Dxf4xtSk.js} +1 -1
  29. package/src/assets/web-panel/assets/{Federation-BCovZaUn.js → Federation-DOiG3i_1.js} +1 -1
  30. package/src/assets/web-panel/assets/{FormItemContext-DjlFdVFk.js → FormItemContext-BzbMcFB9.js} +1 -1
  31. package/src/assets/web-panel/assets/{GenericCardRenderer-DoEZ7fcS.js → GenericCardRenderer-VbBrYttP.js} +1 -1
  32. package/src/assets/web-panel/assets/{Git-CV0-6GPI.js → Git-CLG8E6ED.js} +2 -2
  33. package/src/assets/web-panel/assets/{Governance-B_SFN50w.js → Governance-LGJQfk_q.js} +1 -1
  34. package/src/assets/web-panel/assets/{Inference-heI0Eo6k.js → Inference-DlQKx9lF.js} +1 -1
  35. package/src/assets/web-panel/assets/{KnowledgeGraph-Cz5INS5p.js → KnowledgeGraph-DU8vEouV.js} +1 -1
  36. package/src/assets/web-panel/assets/{Logs-BSFxiHkg.js → Logs-FZIQOdln.js} +2 -2
  37. package/src/assets/web-panel/assets/MarkdownRenderer-BJNWIrbw.js +1 -0
  38. package/src/assets/web-panel/assets/{Marketplace-0TWTpE7k.js → Marketplace-C_xd-zZI.js} +1 -1
  39. package/src/assets/web-panel/assets/{McpTools-CV4-wBLm.js → McpTools-q-YktphS.js} +5 -5
  40. package/src/assets/web-panel/assets/{Memory-x5bH5rw5.js → Memory-Cq9OhDin.js} +2 -2
  41. package/src/assets/web-panel/assets/{MobileBridge-DaBmUu9G.js → MobileBridge-B03_R98T.js} +1 -1
  42. package/src/assets/web-panel/assets/MobileProjects-DZrk-qmF.js +1 -0
  43. package/src/assets/web-panel/assets/{Mtc-B_zKEBif.js → Mtc-BidCKW28.js} +5 -5
  44. package/src/assets/web-panel/assets/{MtcAudit-BCfKD2yg.js → MtcAudit-r-iaMVM7.js} +2 -2
  45. package/src/assets/web-panel/assets/{Multisig-DgYfj6c_.js → Multisig-CgTUFvff.js} +3 -3
  46. package/src/assets/web-panel/assets/{NLProgramming-Dz09lFUm.js → NLProgramming-B7NvzfAV.js} +1 -1
  47. package/src/assets/web-panel/assets/{Notes-Cuj6ugBE.js → Notes-80CWNqJI.js} +3 -3
  48. package/src/assets/web-panel/assets/{NotificationSettings-DK2WXjhg.js → NotificationSettings-0H6xdiY3.js} +1 -1
  49. package/src/assets/web-panel/assets/{OrderTableRenderer-D1IAOQoo.js → OrderTableRenderer-CKP4AFWy.js} +1 -1
  50. package/src/assets/web-panel/assets/{Organization-9fqnPvTw.js → Organization-B8sxCV_h.js} +4 -4
  51. package/src/assets/web-panel/assets/{Overflow-n5xHkhow.js → Overflow-CW9JBBhQ.js} +1 -1
  52. package/src/assets/web-panel/assets/{P2P-B5AUWwH-.js → P2P-bdSQV7oZ.js} +2 -2
  53. package/src/assets/web-panel/assets/{PdhVaultBrowser-CoUDV6dy.js → PdhVaultBrowser-DQad42nW.js} +4 -4
  54. package/src/assets/web-panel/assets/{Permissions-DPRmQf_H.js → Permissions-CueexxTR.js} +4 -4
  55. package/src/assets/web-panel/assets/{PersonalDataHub-B37bsbEN.js → PersonalDataHub-fqxflhR8.js} +6 -6
  56. package/src/assets/web-panel/assets/{Pipeline-CCAuPqu9.js → Pipeline-KFwwnIMC.js} +1 -1
  57. package/src/assets/web-panel/assets/{Privacy-B81msZdv.js → Privacy-ClnD9buY.js} +1 -1
  58. package/src/assets/web-panel/assets/{ProjectInit-B-Py2NbF.js → ProjectInit-CZEz6sm3.js} +2 -2
  59. package/src/assets/web-panel/assets/{ProjectSettings-CYsneiRB.js → ProjectSettings-B6NzpmOI.js} +2 -2
  60. package/src/assets/web-panel/assets/{Projects-Y975Mh6R.js → Projects-DYyuwPCs.js} +1 -1
  61. package/src/assets/web-panel/assets/Providers-C3tz_jdh.js +1 -0
  62. package/src/assets/web-panel/assets/{QrScannerModal-Bj_6gJ0l.js → QrScannerModal-BzuOBbqB.js} +1 -1
  63. package/src/assets/web-panel/assets/{QuickAsk-BgpBCMPE.js → QuickAsk-CmNO9qSj.js} +1 -1
  64. package/src/assets/web-panel/assets/{Recommend-DhUW3YUA.js → Recommend-VJNj3_NF.js} +1 -1
  65. package/src/assets/web-panel/assets/{RemoteSession-DdeyfbkA.js → RemoteSession-CrbdbSBF.js} +2 -2
  66. package/src/assets/web-panel/assets/{Reputation-fxW421xd.js → Reputation-DTyMd45j.js} +1 -1
  67. package/src/assets/web-panel/assets/{Row-De73w1og.js → Row-DHrQ-Icz.js} +1 -1
  68. package/src/assets/web-panel/assets/{RssFeed-3US_Q_lj.js → RssFeed-DqWxY9c5.js} +2 -2
  69. package/src/assets/web-panel/assets/{Search-DbZ_DU74.js → Search-ctF1Ks3J.js} +1 -1
  70. package/src/assets/web-panel/assets/{Security-DoIU1vMF.js → Security-BrQz8uo5.js} +4 -4
  71. package/src/assets/web-panel/assets/{Services-DB_RGqpc.js → Services-D4scVFar.js} +2 -2
  72. package/src/assets/web-panel/assets/{Skeleton-DnzLaayC.js → Skeleton-oUScaOjv.js} +1 -1
  73. package/src/assets/web-panel/assets/{Skills-VzVXHENF.js → Skills-CL-y0eLt.js} +1 -1
  74. package/src/assets/web-panel/assets/{Sla-DMfvjCAI.js → Sla-DQExhwXj.js} +1 -1
  75. package/src/assets/web-panel/assets/{SpeechSettings-DQQOYWbF.js → SpeechSettings-iNp_I7kN.js} +1 -1
  76. package/src/assets/web-panel/assets/{SyncSettings-Bmx3FG1P.js → SyncSettings-CTDlMo0A.js} +2 -2
  77. package/src/assets/web-panel/assets/{Tasks-DOQyfb-2.js → Tasks-4kb8l3sc.js} +1 -1
  78. package/src/assets/web-panel/assets/{Templates-D6hTRT7c.js → Templates-BXcL9oXW.js} +1 -1
  79. package/src/assets/web-panel/assets/{Tenant-CTPQZAzs.js → Tenant-DLE8eS0D.js} +1 -1
  80. package/src/assets/web-panel/assets/{Terminal-B7vII3oH.js → Terminal-DGpUZPPK.js} +2 -2
  81. package/src/assets/web-panel/assets/{TimelineRenderer-BkiFJeXV.js → TimelineRenderer-C1pWXGPC.js} +1 -1
  82. package/src/assets/web-panel/assets/{Tokens-BPPdQMNo.js → Tokens-BYfbfqhZ.js} +1 -1
  83. package/src/assets/web-panel/assets/{Trigger-BxG8JAEI.js → Trigger-K9YfLOCw.js} +1 -1
  84. package/src/assets/web-panel/assets/{Trust-DHD7nMpM.js → Trust-Ctkq77Hy.js} +1 -1
  85. package/src/assets/web-panel/assets/{UkeySign-B4BJpU47.js → UkeySign-C6_CSe9J.js} +1 -1
  86. package/src/assets/web-panel/assets/{VideoEditing-CRiGpFx2.js → VideoEditing-Be3rpOWs.js} +1 -1
  87. package/src/assets/web-panel/assets/{Wallet-BSDSNiem.js → Wallet-D0UIplxk.js} +4 -4
  88. package/src/assets/web-panel/assets/{WebAuthn-DLTUh5Zv.js → WebAuthn-CF9bM9I3.js} +5 -5
  89. package/src/assets/web-panel/assets/{WorkflowEditor-tW2MwjhD.js → WorkflowEditor-D0H1XVPY.js} +1 -1
  90. package/src/assets/web-panel/assets/{chat-SgsLQ3FC.js → chat-Cgzzbe0o.js} +1 -1
  91. package/src/assets/web-panel/assets/{colors-BHjjLIKO.js → colors-BwNxEVS0.js} +1 -1
  92. package/src/assets/web-panel/assets/{compact-item-VceErdKq.js → compact-item-D60Z4Hhs.js} +1 -1
  93. package/src/assets/web-panel/assets/{createContext-BZ332MYl.js → createContext-MFk58XB0.js} +1 -1
  94. package/src/assets/web-panel/assets/devWarning-BJiw369i.js +1 -0
  95. package/src/assets/web-panel/assets/{hasIn-CBTjJOjn.js → hasIn-CRj2SvGO.js} +1 -1
  96. package/src/assets/web-panel/assets/{index-DaCCy6lq.js → index--s45_1cb.js} +1 -1
  97. package/src/assets/web-panel/assets/{index-CMVvxIS8.js → index--tquQ2ZG.js} +1 -1
  98. package/src/assets/web-panel/assets/{index-BhXZEUAc.js → index-1T1rhokn.js} +1 -1
  99. package/src/assets/web-panel/assets/{index-BQSmZGgs.js → index-B-M82_rx.js} +1 -1
  100. package/src/assets/web-panel/assets/{index-BeiqkxJG.js → index-BE_el1Zs.js} +1 -1
  101. package/src/assets/web-panel/assets/{index-29wVzi-k.js → index-BhRkR7IO.js} +1 -1
  102. package/src/assets/web-panel/assets/{index-iYQNU9za.js → index-Bo0y96-K.js} +1 -1
  103. package/src/assets/web-panel/assets/{index-BRWhLbWF.js → index-CJgu5JUK.js} +1 -1
  104. package/src/assets/web-panel/assets/{index-BPXFLYdW.js → index-CLWwdebO.js} +1 -1
  105. package/src/assets/web-panel/assets/{index-Cr4rjlW5.js → index-CNAK5QG_.js} +1 -1
  106. package/src/assets/web-panel/assets/{index-CaIS_TNX.js → index-CQjJjPRD.js} +1 -1
  107. package/src/assets/web-panel/assets/{index-1n7xjKDU.js → index-CTDbAfae.js} +1 -1
  108. package/src/assets/web-panel/assets/{index-BETEjuNr.js → index-CVI_9sRK.js} +1 -1
  109. package/src/assets/web-panel/assets/{index-By5jBtkV.js → index-CY14oMQK.js} +1 -1
  110. package/src/assets/web-panel/assets/{index-DgPvuIn2.js → index-CbiTJkZf.js} +1 -1
  111. package/src/assets/web-panel/assets/index-CiMouosn.js +1 -0
  112. package/src/assets/web-panel/assets/{index-CawpdGTW.js → index-CqWpxMqx.js} +1 -1
  113. package/src/assets/web-panel/assets/{index-_PHrIO4O.js → index-CvB8lYUR.js} +1 -1
  114. package/src/assets/web-panel/assets/{index-BOtvPdHt.js → index-D51XwtCY.js} +1 -1
  115. package/src/assets/web-panel/assets/{index-CrUgDQkf.js → index-D6HvK_1O.js} +1 -1
  116. package/src/assets/web-panel/assets/{index-BY1wexYG.js → index-D9jVIwLJ.js} +1 -1
  117. package/src/assets/web-panel/assets/{index-C_B9rFQE.js → index-DA4tE98u.js} +1 -1
  118. package/src/assets/web-panel/assets/{index-B5KAGxNo.js → index-DBwOtXw0.js} +1 -1
  119. package/src/assets/web-panel/assets/{index-BC1xuQgX.js → index-DKdNzTIb.js} +1 -1
  120. package/src/assets/web-panel/assets/{index-B2u26AS3.js → index-DRySdmvN.js} +1 -1
  121. package/src/assets/web-panel/assets/{index-DePOeMDM.js → index-DWzyTev7.js} +1 -1
  122. package/src/assets/web-panel/assets/index-DXcKlwy7.js +1 -0
  123. package/src/assets/web-panel/assets/{index-BTlc-Bve.js → index-DYjs7jAP.js} +1 -1
  124. package/src/assets/web-panel/assets/{index-CLc19alH.js → index-DbROzMBp.js} +1 -1
  125. package/src/assets/web-panel/assets/{index-fU7wNHDB.js → index-DhFtk5PR.js} +1 -1
  126. package/src/assets/web-panel/assets/{index-D_SePDWj.js → index-Di2AbMLg.js} +1 -1
  127. package/src/assets/web-panel/assets/{index-vIXOpcjD.js → index-DvmocQsM.js} +1 -1
  128. package/src/assets/web-panel/assets/{index-BcUenmCX.js → index-Dw-o0zEF.js} +1 -1
  129. package/src/assets/web-panel/assets/{index-DUkVN794.js → index-DysPoP38.js} +1 -1
  130. package/src/assets/web-panel/assets/{index-CtjLcCNU.js → index-LX9pJxm_.js} +1 -1
  131. package/src/assets/web-panel/assets/{index-B40dQn0G.js → index-OI_fXWx0.js} +3 -3
  132. package/src/assets/web-panel/assets/{index-FJ3zC9LK.js → index-eDTOX8Xj.js} +1 -1
  133. package/src/assets/web-panel/assets/{index-DdefYBSw.js → index-gWKDsTzF.js} +1 -1
  134. package/src/assets/web-panel/assets/{index-CpumS98o.js → index-q_GDUVAA.js} +1 -1
  135. package/src/assets/web-panel/assets/{initDefaultProps-D1oZNz0D.js → initDefaultProps-C61kZqEd.js} +1 -1
  136. package/src/assets/web-panel/assets/{motion-C8k6doMO.js → motion-DZnT5U1L.js} +1 -1
  137. package/src/assets/web-panel/assets/{move-DJ4qSbYt.js → move-L8wzwbel.js} +1 -1
  138. package/src/assets/web-panel/assets/{mtc-parser-CdpJD85j.js → mtc-parser-DLdYSejc.js} +1 -1
  139. package/src/assets/web-panel/assets/{omit-B0V4slKA.js → omit-D53cbS59.js} +1 -1
  140. package/src/assets/web-panel/assets/{pickAttrs-Di5NiW1F.js → pickAttrs-Bbc5HPtu.js} +1 -1
  141. package/src/assets/web-panel/assets/{placementArrow-B5OMHgEM.js → placementArrow-BkvmO860.js} +1 -1
  142. package/src/assets/web-panel/assets/{responsiveObserve-C6U9IWql.js → responsiveObserve-u2y1ryS1.js} +1 -1
  143. package/src/assets/web-panel/assets/{slide-BMyTH3rN.js → slide-BBqj7svU.js} +1 -1
  144. package/src/assets/web-panel/assets/{statusUtils-C7d6BG7Z.js → statusUtils-GN0mqu2d.js} +1 -1
  145. package/src/assets/web-panel/assets/{styleChecker-CR50QnQy.js → styleChecker-BvwPKoBT.js} +1 -1
  146. package/src/assets/web-panel/assets/{useFlexGapSupport-DBEeoMb8.js → useFlexGapSupport-CCt8tnfY.js} +1 -1
  147. package/src/assets/web-panel/assets/{useFs-3r099cjv.js → useFs-DXAxl2SV.js} +1 -1
  148. package/src/assets/web-panel/assets/{usePersonalDataHub-C5IYNdTK.js → usePersonalDataHub-DihlR2dj.js} +1 -1
  149. package/src/assets/web-panel/assets/{vnode-DmEOLRHf.js → vnode-D7BOX2gi.js} +1 -1
  150. package/src/assets/web-panel/assets/{zoom-DnpUiVCB.js → zoom-CVbO-nL7.js} +1 -1
  151. package/src/assets/web-panel/index.html +1 -1
  152. package/src/command-help-index.json +4 -4
  153. package/src/commands/agent.js +3 -3
  154. package/src/commands/background-session.js +145 -0
  155. package/src/commands/mcp.js +244 -7
  156. package/src/commands/serve.js +1 -1
  157. package/src/commands/session-show.js +129 -0
  158. package/src/commands/session.js +97 -145
  159. package/src/commands/skill.js +87 -50
  160. package/src/data/changelog.json +40 -1
  161. package/src/gateways/ws/session-protocol.js +14 -4
  162. package/src/gateways/ws/ws-agent-handler.js +39 -1
  163. package/src/harness/jsonl-session-store.js +1547 -127
  164. package/src/harness/mcp-client.js +2690 -409
  165. package/src/harness/session-index.js +80 -80
  166. package/src/harness/session-list-index.js +210 -7
  167. package/src/lazy-dispatch.js +76 -8
  168. package/src/lib/abort-utils.js +68 -0
  169. package/src/lib/agent-core.js +1 -59
  170. package/src/lib/background-interaction-journal.js +2 -2
  171. package/src/lib/chat-core.js +24 -6
  172. package/src/lib/command-lifecycle-report.js +516 -0
  173. package/src/lib/cowork-mcp-tools.js +47 -2
  174. package/src/lib/cowork-task-runner.js +11 -3
  175. package/src/lib/doctor-checkup.js +9 -7
  176. package/src/lib/learning/skill-improver.js +21 -2
  177. package/src/lib/mcp-call-ledger-store.js +116 -3
  178. package/src/lib/mcp-call-ledger.js +59 -14
  179. package/src/lib/mcp-client.js +1 -0
  180. package/src/lib/mcp-stdio-environment.js +48 -0
  181. package/src/lib/mcp-stdio-executable-identity.js +1133 -0
  182. package/src/lib/mcp-stdio-execution-authority.js +363 -0
  183. package/src/lib/mcp-stdio-package-materialization.js +1424 -0
  184. package/src/lib/mcp-tool-metadata.js +436 -0
  185. package/src/lib/mcp-tool-result.js +367 -0
  186. package/src/lib/observability/index.js +71 -0
  187. package/src/lib/packer/native-update-state.js +979 -1
  188. package/src/lib/packer/pack-update-applier.js +1050 -125
  189. package/src/lib/packer/pkg-config-generator.js +1 -0
  190. package/src/lib/paths.js +53 -0
  191. package/src/lib/pr-link-ledger.js +8 -27
  192. package/src/lib/pr-link-store.js +26 -0
  193. package/src/lib/process-execution-broker/index.js +192 -35
  194. package/src/lib/process-execution-broker/platform-sandbox.js +291 -24
  195. package/src/lib/process-tree-termination.js +253 -0
  196. package/src/lib/recent-session.js +17 -17
  197. package/src/lib/secure-file-identity.js +47 -0
  198. package/src/lib/secure-fs.js +21 -2
  199. package/src/lib/session-anti-rollback-anchor.js +766 -0
  200. package/src/lib/session-host-lease.js +492 -0
  201. package/src/lib/session-host-snapshot.js +9 -5
  202. package/src/lib/session-persistence-failure.js +109 -0
  203. package/src/lib/session-picker.js +26 -3
  204. package/src/lib/session-projection.js +5 -1
  205. package/src/lib/session-search.js +25 -11
  206. package/src/lib/session-tail.js +31 -1
  207. package/src/lib/side-effect-ledger-store.js +2 -2
  208. package/src/lib/skill-budget.js +222 -0
  209. package/src/lib/skill-execution-authority.js +385 -0
  210. package/src/lib/skill-execution-identity.js +39 -2
  211. package/src/lib/skill-loader.js +322 -86
  212. package/src/lib/skill-mcp.js +43 -1
  213. package/src/lib/turn-binding-store.js +2 -2
  214. package/src/repl/agent-repl.js +483 -77
  215. package/src/repl/bg-dashboard.js +81 -28
  216. package/src/repl/chat-repl.js +77 -12
  217. package/src/runtime/agent-core.js +428 -113
  218. package/src/runtime/agent-runtime.js +26 -3
  219. package/src/runtime/cleanup-deadline.js +143 -0
  220. package/src/runtime/headless-runner.js +494 -149
  221. package/src/runtime/headless-stream.js +504 -75
  222. package/src/runtime/mcp-config.js +157 -10
  223. package/src/runtime/mcp-tool-search.js +0 -0
  224. package/src/runtime/output-backpressure.js +419 -0
  225. package/src/runtime/pipe-safety.js +56 -21
  226. package/src/assets/web-panel/assets/ChatBubbleRenderer-BUUyJ5QO.js +0 -1
  227. package/src/assets/web-panel/assets/MarkdownRenderer-1mNozZ8v.js +0 -1
  228. package/src/assets/web-panel/assets/MobileProjects-DqnoFPgR.js +0 -1
  229. package/src/assets/web-panel/assets/Providers-uO-10aP1.js +0 -1
  230. package/src/assets/web-panel/assets/devWarning-DosMaL-k.js +0 -1
  231. package/src/assets/web-panel/assets/index-0O5ePnUq.js +0 -1
  232. package/src/assets/web-panel/assets/index-ChAzPigF.js +0 -1
  233. package/src/lib/skill-process-broker.js +0 -85
@@ -2,7 +2,7 @@
2
2
  * JSONL Session Store — append-only session persistence.
3
3
  */
4
4
 
5
- import {
5
+ import fs, {
6
6
  constants as fsConstants,
7
7
  existsSync,
8
8
  appendFileSync,
@@ -16,6 +16,7 @@ import {
16
16
  fstatSync,
17
17
  lstatSync,
18
18
  readSync,
19
+ writeSync,
19
20
  ftruncateSync,
20
21
  } from "node:fs";
21
22
  import { join, basename, resolve } from "node:path";
@@ -23,9 +24,16 @@ import { createHash, randomUUID } from "node:crypto";
23
24
  import { TextDecoder } from "node:util";
24
25
  import { getHomeDir } from "../lib/paths.js";
25
26
  import { ensurePrivateDirectory, ensurePrivateFile } from "../lib/secure-fs.js";
27
+ import {
28
+ isAffectedWindowsZeroDeviceStatRuntime,
29
+ samePathHandleStableFileIdentity,
30
+ withTrustedFileParentSync,
31
+ } from "../lib/secure-file-identity.js";
26
32
  import {
27
33
  computeEventHash,
34
+ latestChainHash,
28
35
  TRANSCRIPT_CHAIN_STATUS,
36
+ verifyTranscriptText,
29
37
  } from "./transcript-integrity.js";
30
38
  import { withFileLock } from "../lib/with-file-lock.js";
31
39
  import {
@@ -48,11 +56,24 @@ import {
48
56
  publicSessionMeta,
49
57
  readLatestSessionActivity,
50
58
  readSessionMeta,
59
+ readSessionTombstoneMarker,
51
60
  recordSessionDeleted,
52
61
  recordSessionEvent,
53
62
  recordSessionActivity,
54
63
  replaceSessionMeta,
64
+ normalizeSessionGenerationAuthority,
65
+ SESSION_GENERATION_AUTHORITY_FIELD,
66
+ SESSION_GENERATION_AUTHORITY_SCHEMA,
67
+ SESSION_TOMBSTONE_MARKER_SUFFIX,
55
68
  } from "./session-list-index.js";
69
+ import { createSessionPersistenceFailure } from "../lib/session-persistence-failure.js";
70
+ import { withSessionHostWriteAuthority } from "../lib/session-host-lease.js";
71
+ import {
72
+ listSessionAntiRollbackIds,
73
+ publishSessionAntiRollbackAnchor,
74
+ readSessionAntiRollbackAnchor,
75
+ sessionAntiRollbackPredecessorWitness,
76
+ } from "../lib/session-anti-rollback-anchor.js";
56
77
 
57
78
  let securedSessionsDir = null;
58
79
  let securedSessionsDirIdentity = null;
@@ -61,6 +82,7 @@ let securedSessionsDirIdentity = null;
61
82
  // gate. The hooks are inert unless the dedicated child process opts in; this
62
83
  // keeps production behavior and ordinary tests on the exact same append path.
63
84
  export const _sessionScaleFaultHooks = Object.seal({
85
+ beforeTranscriptAppend: null,
64
86
  afterTranscriptAppend: null,
65
87
  afterForkCopy: null,
66
88
  afterForkLineage: null,
@@ -78,6 +100,11 @@ const WS_TURN_INPUT_DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/;
78
100
  const WS_TURN_CLAIM_ID_PATTERN = /^claim-[0-9a-f-]{36}$/;
79
101
  const WS_TURN_FAILURE_CODE_PATTERN = /^[A-Z][A-Z0-9_]{0,63}$/;
80
102
 
103
+ export {
104
+ SESSION_GENERATION_AUTHORITY_FIELD,
105
+ SESSION_GENERATION_AUTHORITY_SCHEMA,
106
+ };
107
+
81
108
  function wsTurnError(message, code) {
82
109
  const error = new Error(message);
83
110
  error.code = code;
@@ -510,6 +537,100 @@ export function sessionPath(sessionId) {
510
537
  return join(getSessionsDir(), `${sessionId}.jsonl`);
511
538
  }
512
539
 
540
+ function physicalTranscriptStateFromStats(stats) {
541
+ const exact = (value) =>
542
+ typeof value === "bigint" ? String(value) : undefined;
543
+ const legacyNumber = (value) => Number(value);
544
+ const timeMs = (name) => {
545
+ const nanoseconds = stats[`${name}Ns`];
546
+ return typeof nanoseconds === "bigint"
547
+ ? Number(nanoseconds / 1_000_000n) +
548
+ Number(nanoseconds % 1_000_000n) / 1_000_000
549
+ : Number(stats[`${name}Ms`]);
550
+ };
551
+ return Object.freeze({
552
+ // Keep the rounded legacy fields so metadata produced by older releases
553
+ // remains readable. BigIntStats supplies the exact witness used by new
554
+ // writers; this avoids Node 22.12 Windows fstat/lstat precision drift.
555
+ dev: String(legacyNumber(stats.dev)),
556
+ ino: String(legacyNumber(stats.ino)),
557
+ size: legacyNumber(stats.size),
558
+ mtimeMs: timeMs("mtime"),
559
+ ctimeMs: timeMs("ctime"),
560
+ devExact: exact(stats.dev),
561
+ inoExact: exact(stats.ino),
562
+ sizeExact: exact(stats.size),
563
+ mtimeNs: exact(stats.mtimeNs),
564
+ ctimeNs: exact(stats.ctimeNs),
565
+ });
566
+ }
567
+
568
+ function readPhysicalTranscriptStats(filePath) {
569
+ const stats = lstatSync(filePath, { bigint: true });
570
+ if (stats.isSymbolicLink() || !stats.isFile()) {
571
+ const error = new Error("Session transcript path is not a regular file");
572
+ error.code = "SESSION_TRANSCRIPT_IDENTITY_CHANGED";
573
+ error.commitState = "not-committed";
574
+ throw error;
575
+ }
576
+ return stats;
577
+ }
578
+
579
+ function readPhysicalTranscriptState(filePath) {
580
+ return physicalTranscriptStateFromStats(
581
+ readPhysicalTranscriptStats(filePath),
582
+ );
583
+ }
584
+
585
+ function samePhysicalTranscriptState(left, right, { content = true } = {}) {
586
+ if (!left || !right) return left === right;
587
+ const sameField = (exactField, legacyField, { numeric = false } = {}) => {
588
+ if (left[exactField] != null && right[exactField] != null) {
589
+ return String(left[exactField]) === String(right[exactField]);
590
+ }
591
+ return numeric
592
+ ? Number(left[legacyField]) === Number(right[legacyField])
593
+ : String(left[legacyField]) === String(right[legacyField]);
594
+ };
595
+ return (
596
+ sameField("devExact", "dev") &&
597
+ sameField("inoExact", "ino") &&
598
+ (!content ||
599
+ (sameField("sizeExact", "size", { numeric: true }) &&
600
+ sameField("mtimeNs", "mtimeMs", { numeric: true }) &&
601
+ sameField("ctimeNs", "ctimeMs", { numeric: true })))
602
+ );
603
+ }
604
+
605
+ function samePhysicalTranscriptContentState(left, right) {
606
+ if (!left || !right) return left === right;
607
+ const sameField = (exactField, legacyField) => {
608
+ if (left[exactField] != null && right[exactField] != null) {
609
+ return String(left[exactField]) === String(right[exactField]);
610
+ }
611
+ return Number(left[legacyField]) === Number(right[legacyField]);
612
+ };
613
+ return (
614
+ sameField("sizeExact", "size") &&
615
+ sameField("mtimeNs", "mtimeMs") &&
616
+ sameField("ctimeNs", "ctimeMs")
617
+ );
618
+ }
619
+
620
+ function withTranscriptFileParent(filePath, task) {
621
+ if (!isAffectedWindowsZeroDeviceStatRuntime()) {
622
+ return task({ canonicalPath: filePath, parentDevice: null });
623
+ }
624
+ return withTrustedFileParentSync(fs, filePath, task);
625
+ }
626
+
627
+ function transcriptIdentityError(commitState, reason) {
628
+ const error = new Error(`Session transcript identity changed: ${reason}`);
629
+ error.code = "SESSION_TRANSCRIPT_IDENTITY_CHANGED";
630
+ error.commitState = commitState;
631
+ return error;
632
+ }
633
+
513
634
  export function appendTokenUsage(sessionId, usage) {
514
635
  appendEvent(sessionId, "token_usage", usage || {});
515
636
  }
@@ -660,19 +781,26 @@ function appendVerifiedWsAuthorityEventLocked(
660
781
  const core = { type, timestamp: Date.now(), data };
661
782
  const hash = computeEventHash(previousHeadHash, core);
662
783
  const event = { ...core, prevHash: previousHeadHash, hash };
663
- appendFileSync(filePath, `${JSON.stringify(event)}\n`, {
664
- encoding: "utf8",
665
- mode: 0o600,
666
- });
667
784
  ensurePrivateFile(filePath);
668
- runSessionScaleFaultHook("afterTranscriptAppend", {
785
+ const expectedState = readPhysicalTranscriptState(filePath);
786
+ const transcriptState = appendTranscriptEvent(
669
787
  sessionId,
670
788
  type,
671
789
  event,
672
790
  filePath,
673
- });
791
+ expectedState,
792
+ );
674
793
  try {
675
- recordSessionEvent(getSessionsDir(), sessionId, event, hash);
794
+ const nextMeta = recordSessionEvent(
795
+ getSessionsDir(),
796
+ sessionId,
797
+ event,
798
+ hash,
799
+ {
800
+ transcriptState,
801
+ },
802
+ );
803
+ publishSessionAntiRollbackWitness(sessionId, nextMeta, filePath, "live");
676
804
  } catch (cause) {
677
805
  const error = new Error(
678
806
  `Session authority anchor could not be persisted: ${sessionId}`,
@@ -710,9 +838,17 @@ function appendVerifiedWsAuthorityEventLocked(
710
838
  return Object.freeze({ hash, event });
711
839
  }
712
840
 
841
+ function withSessionHostWriterLock(sessionId, filePath, task, options) {
842
+ return withFileLock(
843
+ filePath,
844
+ () => withSessionHostWriteAuthority(sessionId, task),
845
+ options,
846
+ );
847
+ }
848
+
713
849
  function withVerifiedWsTurnLock(sessionId, task) {
714
850
  const filePath = sessionPath(sessionId);
715
- return withFileLock(filePath, () => task(filePath), {
851
+ return withSessionHostWriterLock(sessionId, filePath, () => task(filePath), {
716
852
  failIfUnavailable: true,
717
853
  timeoutMs: 30_000,
718
854
  retryMs: 1,
@@ -739,6 +875,153 @@ function encodeEventMessageProvenance(type, data) {
739
875
  return data;
740
876
  }
741
877
 
878
+ function appendTranscriptEvent(
879
+ sessionId,
880
+ type,
881
+ event,
882
+ filePath,
883
+ expectedState,
884
+ ) {
885
+ const payload = { sessionId, type, event, filePath };
886
+ try {
887
+ runSessionScaleFaultHook("beforeTranscriptAppend", payload);
888
+ } catch (cause) {
889
+ throw createSessionPersistenceFailure(cause, {
890
+ sessionId,
891
+ operation: "transcript-append",
892
+ commitState: "not-committed",
893
+ });
894
+ }
895
+ try {
896
+ return withTranscriptFileParent(
897
+ filePath,
898
+ ({ canonicalPath, parentDevice }) =>
899
+ appendTranscriptEventAtPath({
900
+ sessionId,
901
+ event,
902
+ payload,
903
+ filePath: canonicalPath,
904
+ expectedState,
905
+ parentDevice,
906
+ }),
907
+ );
908
+ } catch (cause) {
909
+ if (
910
+ cause?.code === "SESSION_TRANSCRIPT_IDENTITY_CHANGED" ||
911
+ cause?.operation === "transcript-append" ||
912
+ cause?.operation === "transcript-settlement"
913
+ ) {
914
+ throw cause;
915
+ }
916
+ throw createSessionPersistenceFailure(cause, {
917
+ sessionId,
918
+ operation: "transcript-settlement",
919
+ commitState: "unknown",
920
+ });
921
+ }
922
+ }
923
+
924
+ function appendTranscriptEventAtPath({
925
+ sessionId,
926
+ event,
927
+ payload,
928
+ filePath,
929
+ expectedState,
930
+ parentDevice,
931
+ }) {
932
+ let fd = null;
933
+ let wroteBytes = false;
934
+ let appendCompleted = false;
935
+ try {
936
+ let flags = fsConstants.O_WRONLY | fsConstants.O_APPEND;
937
+ if (expectedState === null) {
938
+ flags |= fsConstants.O_CREAT | fsConstants.O_EXCL;
939
+ }
940
+ if (typeof fsConstants.O_NOFOLLOW === "number") {
941
+ flags |= fsConstants.O_NOFOLLOW;
942
+ }
943
+ fd = openSync(filePath, flags, 0o600);
944
+ const openedStats = fstatSync(fd, { bigint: true });
945
+ const openedState = physicalTranscriptStateFromStats(openedStats);
946
+ if (expectedState !== null) {
947
+ if (parentDevice === null) {
948
+ if (!samePhysicalTranscriptState(expectedState, openedState)) {
949
+ throw transcriptIdentityError(
950
+ "not-committed",
951
+ "the append descriptor does not match the verified path",
952
+ );
953
+ }
954
+ } else {
955
+ const publishedBeforeStats = readPhysicalTranscriptStats(filePath);
956
+ const publishedBeforeState =
957
+ physicalTranscriptStateFromStats(publishedBeforeStats);
958
+ if (
959
+ !samePhysicalTranscriptState(expectedState, publishedBeforeState) ||
960
+ !samePathHandleStableFileIdentity(
961
+ publishedBeforeStats,
962
+ openedStats,
963
+ parentDevice,
964
+ )
965
+ ) {
966
+ throw transcriptIdentityError(
967
+ "not-committed",
968
+ "the append descriptor does not match the verified path",
969
+ );
970
+ }
971
+ }
972
+ }
973
+ const bytes = Buffer.from(`${JSON.stringify(event)}\n`, "utf8");
974
+ let offset = 0;
975
+ while (offset < bytes.length) {
976
+ const written = writeSync(fd, bytes, offset, bytes.length - offset);
977
+ if (!Number.isSafeInteger(written) || written <= 0) {
978
+ throw new Error("Session transcript append made no forward progress");
979
+ }
980
+ offset += written;
981
+ wroteBytes = true;
982
+ }
983
+ appendCompleted = true;
984
+ runSessionScaleFaultHook("afterTranscriptAppend", payload);
985
+ const descriptorStats = fstatSync(fd, { bigint: true });
986
+ const descriptorState = physicalTranscriptStateFromStats(descriptorStats);
987
+ const pathStats = readPhysicalTranscriptStats(filePath);
988
+ const pathState = physicalTranscriptStateFromStats(pathStats);
989
+ const publishedIdentityMatches =
990
+ parentDevice === null
991
+ ? samePhysicalTranscriptState(descriptorState, pathState, {
992
+ content: false,
993
+ })
994
+ : samePathHandleStableFileIdentity(
995
+ pathStats,
996
+ descriptorStats,
997
+ parentDevice,
998
+ );
999
+ if (!publishedIdentityMatches) {
1000
+ throw transcriptIdentityError(
1001
+ "unknown",
1002
+ "the published path no longer names the appended descriptor",
1003
+ );
1004
+ }
1005
+ return pathState;
1006
+ } catch (cause) {
1007
+ if (cause?.code === "SESSION_TRANSCRIPT_IDENTITY_CHANGED") throw cause;
1008
+ throw createSessionPersistenceFailure(cause, {
1009
+ sessionId,
1010
+ operation: appendCompleted
1011
+ ? "transcript-settlement"
1012
+ : "transcript-append",
1013
+ // ENOSPC can follow a short write. EROFS fails before the append can
1014
+ // mutate the target.
1015
+ commitState:
1016
+ !appendCompleted && (cause?.code === "EROFS" || !wroteBytes)
1017
+ ? "not-committed"
1018
+ : "unknown",
1019
+ });
1020
+ } finally {
1021
+ if (fd !== null) closeSync(fd);
1022
+ }
1023
+ }
1024
+
742
1025
  const SESSION_AUTHORITY_RECOVERY_TEXT_LIMITS = Object.freeze({
743
1026
  safetyId: 512,
744
1027
  safetyIdentity: 512,
@@ -773,6 +1056,174 @@ function sanitizeSessionAuthorityRecoveryEvidence(value) {
773
1056
  return Object.keys(evidence).length > 0 ? Object.freeze(evidence) : null;
774
1057
  }
775
1058
 
1059
+ function sessionDeletedError(sessionId) {
1060
+ const error = new Error(`Session was deleted: ${sessionId}`);
1061
+ error.code = "SESSION_DELETED";
1062
+ return error;
1063
+ }
1064
+
1065
+ function createSessionGenerationAuthority(
1066
+ sessionId,
1067
+ predecessorWitness = null,
1068
+ generationId = `generation-${randomUUID()}`,
1069
+ ) {
1070
+ const previous = normalizeSessionGenerationAuthority(
1071
+ predecessorWitness?.generation,
1072
+ );
1073
+ const isReplacement = predecessorWitness !== null;
1074
+ return Object.freeze({
1075
+ schema: SESSION_GENERATION_AUTHORITY_SCHEMA,
1076
+ sessionId,
1077
+ generationId,
1078
+ ordinal:
1079
+ previous?.sessionId === sessionId
1080
+ ? previous.ordinal + 1
1081
+ : isReplacement
1082
+ ? 2
1083
+ : 1,
1084
+ predecessor: isReplacement
1085
+ ? Object.freeze({
1086
+ kind:
1087
+ previous?.sessionId === sessionId
1088
+ ? "tombstone"
1089
+ : "legacy-tombstone",
1090
+ generationId:
1091
+ previous?.sessionId === sessionId ? previous.generationId : null,
1092
+ headHash:
1093
+ typeof predecessorWitness?.last_hash === "string" &&
1094
+ /^[0-9a-f]{64}$/.test(predecessorWitness.last_hash)
1095
+ ? predecessorWitness.last_hash
1096
+ : null,
1097
+ eventCount: Math.max(0, Number(predecessorWitness?.event_count) || 0),
1098
+ tombstonedAtMs: Number.isSafeInteger(
1099
+ Number(predecessorWitness?.deleted_at_ms),
1100
+ )
1101
+ ? Math.max(0, Number(predecessorWitness.deleted_at_ms))
1102
+ : null,
1103
+ })
1104
+ : null,
1105
+ });
1106
+ }
1107
+
1108
+ function encodeSessionGenerationData(data, authority = null) {
1109
+ if (!data || typeof data !== "object" || Array.isArray(data)) {
1110
+ return authority
1111
+ ? { [SESSION_GENERATION_AUTHORITY_FIELD]: authority }
1112
+ : data;
1113
+ }
1114
+ const clean = { ...data };
1115
+ // The namespace generation is store-owned authority. Callers may not forge
1116
+ // it on arbitrary events or choose a replacement predecessor.
1117
+ delete clean[SESSION_GENERATION_AUTHORITY_FIELD];
1118
+ if (authority) clean[SESSION_GENERATION_AUTHORITY_FIELD] = authority;
1119
+ return clean;
1120
+ }
1121
+
1122
+ function sessionAntiRollbackCandidateFromWitness(witness, status = "live") {
1123
+ if (!witness || typeof witness !== "object") return null;
1124
+ const eventCount = Math.max(0, Number(witness.event_count) || 0);
1125
+ const headHash =
1126
+ typeof witness.last_hash === "string" &&
1127
+ /^[0-9a-f]{64}$/.test(witness.last_hash)
1128
+ ? witness.last_hash
1129
+ : null;
1130
+ const generation = normalizeSessionGenerationAuthority(witness.generation);
1131
+ const legacyDeletion =
1132
+ status === "deleted" && generation === null && headHash === null;
1133
+ if (!legacyDeletion && (eventCount === 0) !== (headHash === null))
1134
+ return null;
1135
+ return Object.freeze({
1136
+ status,
1137
+ generation,
1138
+ headHash,
1139
+ eventCount,
1140
+ deletedAtMs:
1141
+ status === "deleted"
1142
+ ? Math.max(
1143
+ 0,
1144
+ Number(witness.deleted_at_ms || witness.updated_at_ms) || 0,
1145
+ )
1146
+ : null,
1147
+ });
1148
+ }
1149
+
1150
+ function antiRollbackAnchorMatchesCandidate(anchor, candidate) {
1151
+ return (
1152
+ anchor !== null &&
1153
+ candidate !== null &&
1154
+ anchor.status === candidate.status &&
1155
+ JSON.stringify(anchor.generation) ===
1156
+ JSON.stringify(candidate.generation) &&
1157
+ anchor.headHash === candidate.headHash &&
1158
+ anchor.eventCount === candidate.eventCount &&
1159
+ anchor.deletedAtMs === candidate.deletedAtMs
1160
+ );
1161
+ }
1162
+
1163
+ function transcriptHashAtEventCount(filePath, eventCount) {
1164
+ if (eventCount === 0) return null;
1165
+ let count = 0;
1166
+ for (const { line } of iterateFileLinesSync(filePath)) {
1167
+ let event;
1168
+ try {
1169
+ event = JSON.parse(line);
1170
+ } catch {
1171
+ continue;
1172
+ }
1173
+ if (typeof event?.hash !== "string") continue;
1174
+ count += 1;
1175
+ if (count === eventCount) return event.hash;
1176
+ }
1177
+ return null;
1178
+ }
1179
+
1180
+ function publishSessionAntiRollbackWitness(
1181
+ sessionId,
1182
+ witness,
1183
+ filePath,
1184
+ status = "live",
1185
+ ) {
1186
+ const candidate = sessionAntiRollbackCandidateFromWitness(witness, status);
1187
+ if (candidate === null) {
1188
+ const error = new Error(
1189
+ `Session anti-rollback candidate is incomplete: ${sessionId}`,
1190
+ );
1191
+ error.code = "CC_SESSION_ANTI_ROLLBACK_UNAVAILABLE";
1192
+ throw error;
1193
+ }
1194
+ return publishSessionAntiRollbackAnchor(sessionId, candidate, {
1195
+ provePrefix: (current) =>
1196
+ current.status === "live" &&
1197
+ existsSync(filePath) &&
1198
+ transcriptHashAtEventCount(filePath, current.eventCount) ===
1199
+ current.headHash,
1200
+ });
1201
+ }
1202
+
1203
+ function assertSessionAntiRollbackBeforeWrite(
1204
+ sessionId,
1205
+ witness,
1206
+ filePath,
1207
+ status = "live",
1208
+ ) {
1209
+ const current = readSessionAntiRollbackAnchor(sessionId);
1210
+ if (current === null && witness === null) return null;
1211
+ const candidate = sessionAntiRollbackCandidateFromWitness(witness, status);
1212
+ if (candidate === null) return current;
1213
+ if (antiRollbackAnchorMatchesCandidate(current, candidate)) return current;
1214
+
1215
+ if (status === "live" && existsSync(filePath)) {
1216
+ const verification = verifyTranscriptFile(filePath);
1217
+ assertLocalVerifiedTranscriptAnchor(sessionId, verification);
1218
+ }
1219
+ return publishSessionAntiRollbackWitness(
1220
+ sessionId,
1221
+ witness,
1222
+ filePath,
1223
+ status,
1224
+ );
1225
+ }
1226
+
776
1227
  function appendEventLocked(
777
1228
  sessionId,
778
1229
  type,
@@ -780,20 +1231,117 @@ function appendEventLocked(
780
1231
  { expectedHeadHash, compareHead = false, requireIndexAnchor = false } = {},
781
1232
  ) {
782
1233
  const filePath = sessionPath(sessionId);
783
- return withFileLock(
1234
+ return withSessionHostWriterLock(
1235
+ sessionId,
784
1236
  filePath,
785
1237
  () => {
786
- const existingMeta = readSessionMeta(getSessionsDir(), sessionId);
1238
+ let existingMeta = readSessionMeta(getSessionsDir(), sessionId);
1239
+ const marker = readSessionTombstoneMarker(getSessionsDir(), sessionId);
1240
+ const externalAnchor = readSessionAntiRollbackAnchor(sessionId);
1241
+ const presence = getSessionPresence(sessionId);
1242
+ const transcriptExists = existsSync(filePath);
1243
+ const startsNewGeneration =
1244
+ presence === SESSION_PRESENCE.TOMBSTONED && type === "session_start";
1245
+ if (presence === SESSION_PRESENCE.CONFLICT) {
1246
+ if (marker !== null || existingMeta?.deleted === true) {
1247
+ throw sessionDeletedError(sessionId);
1248
+ }
1249
+ throw unverifiedTranscriptError(
1250
+ sessionId,
1251
+ transcriptAnchorMismatchVerification(),
1252
+ );
1253
+ }
787
1254
  if (
788
- !existsSync(filePath) &&
789
- existingMeta?.deleted === true &&
1255
+ presence === SESSION_PRESENCE.TOMBSTONED &&
790
1256
  type !== "session_start"
791
1257
  ) {
792
- const error = new Error(`Session was deleted: ${sessionId}`);
793
- error.code = "SESSION_DELETED";
794
- throw error;
1258
+ throw sessionDeletedError(sessionId);
795
1259
  }
1260
+ if (
1261
+ presence === SESSION_PRESENCE.MISSING_TRANSCRIPT ||
1262
+ (!transcriptExists &&
1263
+ existingMeta !== null &&
1264
+ existingMeta?.deleted !== true)
1265
+ ) {
1266
+ throw unverifiedTranscriptError(
1267
+ sessionId,
1268
+ missingTranscriptVerification(),
1269
+ );
1270
+ }
1271
+ const duplicateLiveStart =
1272
+ presence === SESSION_PRESENCE.PRESENT && type === "session_start";
796
1273
  const { prevHash, recovery } = _resolveChainTail(filePath);
1274
+ let expectedTranscriptState = transcriptExists
1275
+ ? readPhysicalTranscriptState(filePath)
1276
+ : null;
1277
+ if (
1278
+ transcriptExists &&
1279
+ existingMeta?.transcript &&
1280
+ (!samePhysicalTranscriptState(
1281
+ existingMeta.transcript,
1282
+ expectedTranscriptState,
1283
+ { content: recovery === null },
1284
+ ) ||
1285
+ Number(existingMeta.transcript.size) !==
1286
+ Number(expectedTranscriptState.size))
1287
+ ) {
1288
+ throw transcriptIdentityError(
1289
+ "not-committed",
1290
+ "the transcript no longer matches its persisted physical witness",
1291
+ );
1292
+ }
1293
+ if (transcriptExists) {
1294
+ ensurePrivateFile(filePath);
1295
+ expectedTranscriptState = readPhysicalTranscriptState(filePath);
1296
+ }
1297
+ if (
1298
+ transcriptExists &&
1299
+ existingMeta !== null &&
1300
+ existingMeta?.deleted !== true &&
1301
+ (existingMeta.last_hash ?? null) !== prevHash
1302
+ ) {
1303
+ throw unverifiedTranscriptError(
1304
+ sessionId,
1305
+ transcriptAnchorMismatchVerification(prevHash),
1306
+ );
1307
+ }
1308
+ if (transcriptExists && existingMeta === null) {
1309
+ const verification = verifyTranscriptFile(filePath);
1310
+ if (
1311
+ verification.status === TRANSCRIPT_CHAIN_STATUS.TAMPERED ||
1312
+ verification.malformedLines > 0 ||
1313
+ verification.truncatedTail
1314
+ ) {
1315
+ throw unverifiedTranscriptError(sessionId, verification);
1316
+ }
1317
+ // A sidecar is rebuildable, but appending before rebuilding it would
1318
+ // publish a count of one for a multi-event transcript. Restore the
1319
+ // projection under the same writer lock before advancing the chain.
1320
+ rebuildSessionMetaUnlocked(getSessionsDir(), sessionId, filePath);
1321
+ existingMeta = readSessionMeta(getSessionsDir(), sessionId);
1322
+ }
1323
+ if (transcriptExists) {
1324
+ assertSessionAntiRollbackBeforeWrite(
1325
+ sessionId,
1326
+ existingMeta,
1327
+ filePath,
1328
+ "live",
1329
+ );
1330
+ } else if (presence === SESSION_PRESENCE.TOMBSTONED) {
1331
+ assertSessionAntiRollbackBeforeWrite(
1332
+ sessionId,
1333
+ existingMeta ||
1334
+ marker ||
1335
+ sessionAntiRollbackPredecessorWitness(externalAnchor),
1336
+ filePath,
1337
+ "deleted",
1338
+ );
1339
+ }
1340
+ if (duplicateLiveStart) {
1341
+ const error = new Error(`Session already exists: ${sessionId}`);
1342
+ error.code = "SESSION_ALREADY_EXISTS";
1343
+ throw error;
1344
+ }
797
1345
  if (compareHead && prevHash !== (expectedHeadHash || null)) {
798
1346
  const error = new Error(
799
1347
  `Session revision changed for ${sessionId}; refresh the checkpoint timeline`,
@@ -803,33 +1351,60 @@ function appendEventLocked(
803
1351
  error.actualHeadHash = prevHash;
804
1352
  throw error;
805
1353
  }
806
- const persistedData = encodeEventMessageProvenance(type, data);
1354
+ const generationAuthority =
1355
+ type === "session_start" &&
1356
+ [SESSION_PRESENCE.ABSENT, SESSION_PRESENCE.TOMBSTONED].includes(
1357
+ presence,
1358
+ )
1359
+ ? createSessionGenerationAuthority(
1360
+ sessionId,
1361
+ startsNewGeneration
1362
+ ? existingMeta ||
1363
+ marker ||
1364
+ sessionAntiRollbackPredecessorWitness(externalAnchor)
1365
+ : null,
1366
+ )
1367
+ : null;
1368
+ const persistedData = encodeSessionGenerationData(
1369
+ encodeEventMessageProvenance(type, data),
1370
+ generationAuthority,
1371
+ );
807
1372
  const core = { type, timestamp: Date.now(), data: persistedData };
808
1373
  const hash = computeEventHash(prevHash, core);
809
1374
  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", {
1375
+ const transcriptState = appendTranscriptEvent(
814
1376
  sessionId,
815
1377
  type,
816
1378
  event,
817
1379
  filePath,
818
- });
1380
+ expectedTranscriptState,
1381
+ );
819
1382
  try {
820
- recordSessionEvent(getSessionsDir(), sessionId, event, hash);
1383
+ const nextMeta = recordSessionEvent(
1384
+ getSessionsDir(),
1385
+ sessionId,
1386
+ event,
1387
+ hash,
1388
+ {
1389
+ resetGeneration: startsNewGeneration,
1390
+ transcriptState,
1391
+ },
1392
+ );
1393
+ publishSessionAntiRollbackWitness(
1394
+ sessionId,
1395
+ nextMeta,
1396
+ filePath,
1397
+ "live",
1398
+ );
821
1399
  } 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.
1400
+ const error = new Error(
1401
+ `Session authority anchor could not be persisted: ${sessionId}`,
1402
+ { cause },
1403
+ );
1404
+ error.code = "SESSION_INDEX_ANCHOR_FAILED";
1405
+ error.sessionId = sessionId;
1406
+ error.commitState = "unknown";
1407
+ throw error;
833
1408
  }
834
1409
  if (requireIndexAnchor) {
835
1410
  const verification = verifyTranscriptFile(filePath);
@@ -848,10 +1423,11 @@ function appendEventLocked(
848
1423
  error.code = "SESSION_INDEX_ANCHOR_FAILED";
849
1424
  error.sessionId = sessionId;
850
1425
  error.verification = verification;
1426
+ error.commitState = "unknown";
851
1427
  throw error;
852
1428
  }
853
1429
  }
854
- return { hash, recovery };
1430
+ return { hash, recovery, commitState: "committed" };
855
1431
  },
856
1432
  {
857
1433
  failIfUnavailable: true,
@@ -864,8 +1440,24 @@ function appendEventLocked(
864
1440
  );
865
1441
  }
866
1442
 
1443
+ function runClassifiedAppend(sessionId, operation, append) {
1444
+ try {
1445
+ return append();
1446
+ } catch (cause) {
1447
+ throw createSessionPersistenceFailure(cause, {
1448
+ sessionId,
1449
+ operation,
1450
+ // Preserve an inner append/settlement classification. Without one,
1451
+ // EROFS defaults to not-committed and ENOSPC remains unknown.
1452
+ commitState: cause?.commitState,
1453
+ });
1454
+ }
1455
+ }
1456
+
867
1457
  export function appendEvent(sessionId, type, data) {
868
- return appendEventLocked(sessionId, type, data);
1458
+ return runClassifiedAppend(sessionId, "append-event", () =>
1459
+ appendEventLocked(sessionId, type, data),
1460
+ );
869
1461
  }
870
1462
 
871
1463
  /**
@@ -873,9 +1465,11 @@ export function appendEvent(sessionId, type, data) {
873
1465
  * crash/tail-truncation anchor are durably updated and still agree.
874
1466
  */
875
1467
  export function appendAuthorityEvent(sessionId, type, data) {
876
- return appendEventLocked(sessionId, type, data, {
877
- requireIndexAnchor: true,
878
- });
1468
+ return runClassifiedAppend(sessionId, "append-authority-event", () =>
1469
+ appendEventLocked(sessionId, type, data, {
1470
+ requireIndexAnchor: true,
1471
+ }),
1472
+ );
879
1473
  }
880
1474
 
881
1475
  /**
@@ -889,10 +1483,12 @@ export function appendEventIfHead(
889
1483
  data,
890
1484
  expectedHeadHash = null,
891
1485
  ) {
892
- return appendEventLocked(sessionId, type, data, {
893
- expectedHeadHash,
894
- compareHead: true,
895
- });
1486
+ return runClassifiedAppend(sessionId, "compare-and-append-event", () =>
1487
+ appendEventLocked(sessionId, type, data, {
1488
+ expectedHeadHash,
1489
+ compareHead: true,
1490
+ }),
1491
+ );
896
1492
  }
897
1493
 
898
1494
  /** Authority-bearing compare-and-append with a mandatory index anchor. */
@@ -902,11 +1498,123 @@ export function appendAuthorityEventIfHead(
902
1498
  data,
903
1499
  expectedHeadHash = null,
904
1500
  ) {
905
- return appendEventLocked(sessionId, type, data, {
906
- expectedHeadHash,
907
- compareHead: true,
908
- requireIndexAnchor: true,
909
- });
1501
+ return runClassifiedAppend(
1502
+ sessionId,
1503
+ "compare-and-append-authority-event",
1504
+ () =>
1505
+ appendEventLocked(sessionId, type, data, {
1506
+ expectedHeadHash,
1507
+ compareHead: true,
1508
+ requireIndexAnchor: true,
1509
+ }),
1510
+ );
1511
+ }
1512
+
1513
+ /**
1514
+ * Validate a caller-owned projection and append one authority event while the
1515
+ * same transcript writer lock remains held. This closes the read/validate to
1516
+ * append race for authorities (such as MCP host fencing) that are derived from
1517
+ * the complete verified transcript rather than from the head hash alone.
1518
+ *
1519
+ * Both callbacks must be synchronous. The projection remains caller-owned and
1520
+ * only its validation result controls whether the event is appended.
1521
+ */
1522
+ export function appendAuthorityEventWithVerifiedProjection(
1523
+ sessionId,
1524
+ type,
1525
+ data,
1526
+ { createProjection, validateProjection } = {},
1527
+ ) {
1528
+ if (
1529
+ typeof createProjection !== "function" ||
1530
+ typeof validateProjection !== "function"
1531
+ ) {
1532
+ throw new TypeError(
1533
+ "Authority projection append requires projection and validation callbacks",
1534
+ );
1535
+ }
1536
+ if (
1537
+ createProjection.constructor?.name === "AsyncFunction" ||
1538
+ validateProjection.constructor?.name === "AsyncFunction"
1539
+ ) {
1540
+ throw new TypeError(
1541
+ "Authority projection append callbacks must be synchronous",
1542
+ );
1543
+ }
1544
+ const filePath = sessionPath(sessionId);
1545
+ return withSessionHostWriterLock(
1546
+ sessionId,
1547
+ filePath,
1548
+ () => {
1549
+ const projection = createProjection();
1550
+ if (
1551
+ !projection ||
1552
+ typeof projection.accept !== "function" ||
1553
+ typeof projection.finish !== "function"
1554
+ ) {
1555
+ throw new TypeError(
1556
+ "Authority projection must provide accept() and finish()",
1557
+ );
1558
+ }
1559
+ if (!existsSync(filePath)) {
1560
+ throw unverifiedTranscriptError(sessionId, {
1561
+ status: "missing",
1562
+ reason: "authority transcript does not exist",
1563
+ lastHash: null,
1564
+ chainedEvents: 0,
1565
+ });
1566
+ }
1567
+ _resolveChainTail(filePath);
1568
+ const verification = verifyTranscriptFile(filePath, {
1569
+ onVerifiedEvent: (event) => {
1570
+ const accepted = projection.accept(event);
1571
+ if (accepted && typeof accepted.then === "function") {
1572
+ throw new TypeError(
1573
+ "Authority projection accept() must be synchronous",
1574
+ );
1575
+ }
1576
+ },
1577
+ });
1578
+ assertVerifiedTranscriptAnchor(sessionId, verification);
1579
+ const authority = Object.freeze({
1580
+ headHash: verification.lastHash,
1581
+ eventCount: verification.chainedEvents,
1582
+ readMessages: () => rebuildVerifiedMessagesFromFile(filePath),
1583
+ });
1584
+ const projected = projection.finish(authority);
1585
+ if (projected && typeof projected.then === "function") {
1586
+ throw new TypeError(
1587
+ "Authority projection finish() must be synchronous",
1588
+ );
1589
+ }
1590
+ const validation = validateProjection(projected, authority);
1591
+ if (validation && typeof validation.then === "function") {
1592
+ throw new TypeError(
1593
+ "Authority projection validation must be synchronous",
1594
+ );
1595
+ }
1596
+ if (validation === false) {
1597
+ throw new Error("Authority projection validation rejected the append");
1598
+ }
1599
+ const persistedData = encodeEventMessageProvenance(type, data);
1600
+ const appended = appendVerifiedWsAuthorityEventLocked(
1601
+ sessionId,
1602
+ filePath,
1603
+ type,
1604
+ persistedData,
1605
+ authority.headHash,
1606
+ );
1607
+ return Object.freeze({ hash: appended.hash });
1608
+ },
1609
+ {
1610
+ failIfUnavailable: true,
1611
+ timeoutMs: 30_000,
1612
+ retryMs: 1,
1613
+ maxRetryMs: 8,
1614
+ retryJitterMs: 4,
1615
+ yieldAfterReleaseMs: 2,
1616
+ },
1617
+ );
910
1618
  }
911
1619
 
912
1620
  /**
@@ -944,16 +1652,18 @@ export function withSessionAuthorityTransaction(
944
1652
  }
945
1653
 
946
1654
  const filePath = sessionPath(sessionId);
947
- return withFileLock(
1655
+ return withSessionHostWriterLock(
1656
+ sessionId,
948
1657
  filePath,
949
1658
  () => {
950
1659
  const existingMeta = readSessionMeta(getSessionsDir(), sessionId);
951
- if (!existsSync(filePath) && existingMeta?.deleted === true) {
1660
+ const persistedWitness = existingMeta;
1661
+ if (persistedWitness?.deleted === true) {
952
1662
  const error = new Error(`Session was deleted: ${sessionId}`);
953
1663
  error.code = "SESSION_DELETED";
954
1664
  throw error;
955
1665
  }
956
- if (!existsSync(filePath) && existingMeta !== null) {
1666
+ if (!existsSync(filePath) && persistedWitness !== null) {
957
1667
  throw unverifiedTranscriptError(sessionId, {
958
1668
  status: "missing",
959
1669
  reason: "session sidecar exists without its transcript",
@@ -1204,8 +1914,19 @@ export function verifySession(sessionId) {
1204
1914
  if (isUnsafeSessionId(sessionId)) {
1205
1915
  return { sessionId, status: "invalid-id", reason: "invalid session id" };
1206
1916
  }
1207
- const filePath = sessionPath(sessionId);
1917
+ const filePath = sessionPath(sessionId);
1918
+ const presence = getSessionPresence(sessionId);
1919
+ if (presence === SESSION_PRESENCE.CONFLICT) {
1920
+ return {
1921
+ sessionId,
1922
+ status: "conflict",
1923
+ reason: "tombstoned session has a restored transcript path",
1924
+ };
1925
+ }
1208
1926
  if (!existsSync(filePath)) {
1927
+ if (presence === SESSION_PRESENCE.MISSING_TRANSCRIPT) {
1928
+ return { sessionId, ...missingTranscriptVerification() };
1929
+ }
1209
1930
  return { sessionId, status: "not-found", reason: "session file not found" };
1210
1931
  }
1211
1932
  return {
@@ -1217,10 +1938,20 @@ export function verifySession(sessionId) {
1217
1938
  export function verifyAllSessions(options = {}) {
1218
1939
  const dir = getSessionsDir();
1219
1940
  if (!existsSync(dir)) return [];
1220
- return readdirSync(dir)
1221
- .filter((file) => file.endsWith(".jsonl"))
1941
+ const sessionIds = new Set(listSessionIds());
1942
+ for (const file of readdirSync(dir)) {
1943
+ if (!file.endsWith(".meta.json")) continue;
1944
+ const sessionId = file.slice(0, -".meta.json".length);
1945
+ if (
1946
+ !isUnsafeSessionId(sessionId) &&
1947
+ getSessionPresence(sessionId) === SESSION_PRESENCE.MISSING_TRANSCRIPT
1948
+ ) {
1949
+ sessionIds.add(sessionId);
1950
+ }
1951
+ }
1952
+ return [...sessionIds]
1222
1953
  .slice(0, options.limit || 1000)
1223
- .map((file) => verifySession(basename(file, ".jsonl")));
1954
+ .map((sessionId) => verifySession(sessionId));
1224
1955
  }
1225
1956
 
1226
1957
  function indexedProjectionMatchesTranscript(meta, verification, validation) {
@@ -1267,6 +1998,32 @@ export function repairSession(sessionId, options = {}) {
1267
1998
  filePath,
1268
1999
  () => {
1269
2000
  const dir = getSessionsDir();
2001
+ if (getSessionPresence(sessionId) === SESSION_PRESENCE.CONFLICT) {
2002
+ return {
2003
+ sessionId,
2004
+ dryRun,
2005
+ changed: false,
2006
+ physicalChanged: false,
2007
+ indexChanged: false,
2008
+ wouldChange: false,
2009
+ action: "none",
2010
+ physicalAction: "none",
2011
+ indexAction: "none",
2012
+ indexRepairRequired: false,
2013
+ indexRebuilt: false,
2014
+ indexRepairError: null,
2015
+ discardedBytes: 0,
2016
+ discardedRecords: 0,
2017
+ healthy: false,
2018
+ transcriptHealthy: false,
2019
+ status: "conflict",
2020
+ before: null,
2021
+ after: null,
2022
+ beforeValidation: null,
2023
+ afterValidation: null,
2024
+ reason: "tombstoned session has a restored transcript path",
2025
+ };
2026
+ }
1270
2027
  const before = verifyTranscriptFile(filePath);
1271
2028
  const beforeValidation = validateJsonlSession(sessionId);
1272
2029
  const repair = inspectPhysicalTail(filePath, { dryRun });
@@ -1363,18 +2120,77 @@ export function listSessionIds() {
1363
2120
  .map((file) => basename(file, ".jsonl"));
1364
2121
  }
1365
2122
 
1366
- /** Resolve an exact id or one unambiguous prefix without reading transcripts. */
1367
- export function resolveSessionId(input) {
2123
+ /**
2124
+ * Enumerate every durable id occupying the canonical JSONL namespace.
2125
+ *
2126
+ * A transcript is not the only authority witness: a live/missing sidecar and
2127
+ * a tombstone marker must continue to fence legacy stores even when the JSONL
2128
+ * path itself is absent. Keeping this enumeration content-free also lets
2129
+ * prefix resolution detect ambiguity without opening transcript bodies.
2130
+ */
2131
+ export function listSessionEvidenceIds() {
2132
+ const dir = getSessionsDir();
2133
+ const ids = new Set(listSessionAntiRollbackIds());
2134
+ if (!existsSync(dir)) return [...ids].sort();
2135
+ for (const file of readdirSync(dir)) {
2136
+ let id = null;
2137
+ if (file.endsWith(".jsonl")) {
2138
+ id = basename(file, ".jsonl");
2139
+ } else if (file.endsWith(".meta.json")) {
2140
+ id = file.slice(0, -".meta.json".length);
2141
+ } else if (file.endsWith(SESSION_TOMBSTONE_MARKER_SUFFIX)) {
2142
+ id = file.slice(0, -SESSION_TOMBSTONE_MARKER_SUFFIX.length);
2143
+ }
2144
+ if (id && !isUnsafeSessionId(id)) ids.add(id);
2145
+ }
2146
+ return [...ids].sort();
2147
+ }
2148
+
2149
+ /**
2150
+ * Resolve an exact id or unambiguous prefix across the complete persisted
2151
+ * namespace, including damaged and tombstoned generations.
2152
+ *
2153
+ * Returning the presence separately from the id prevents callers from using
2154
+ * "not readable" as "not canonical" and then silently consulting a legacy
2155
+ * store. Ambiguity is fail-closed even when only one candidate is currently
2156
+ * readable.
2157
+ */
2158
+ export function resolveSessionAuthority(input) {
1368
2159
  if (isUnsafeSessionId(input)) return null;
1369
- if (sessionExists(input)) return input;
1370
- const matches = listSessionIds().filter((id) => id.startsWith(input));
2160
+ const exactPresence = getSessionPresence(input);
2161
+ if (exactPresence !== SESSION_PRESENCE.ABSENT) {
2162
+ return Object.freeze({
2163
+ id: input,
2164
+ match: "exact",
2165
+ presence: exactPresence,
2166
+ readable: exactPresence === SESSION_PRESENCE.PRESENT,
2167
+ });
2168
+ }
2169
+ const matches = listSessionEvidenceIds()
2170
+ .filter((id) => id.startsWith(input))
2171
+ .map((id) => ({ id, presence: getSessionPresence(id) }))
2172
+ .filter(({ presence }) => presence !== SESSION_PRESENCE.ABSENT);
1371
2173
  if (matches.length > 1) {
1372
2174
  const error = new Error(`Ambiguous session id prefix: ${input}`);
1373
2175
  error.code = "AMBIGUOUS_SESSION_ID";
1374
- error.matches = matches.slice(0, 20);
2176
+ error.matches = matches.slice(0, 20).map(({ id }) => id);
2177
+ error.authorities = matches.slice(0, 20).map((item) => ({ ...item }));
1375
2178
  throw error;
1376
2179
  }
1377
- return matches[0] || null;
2180
+ if (matches.length === 0) return null;
2181
+ const [{ id, presence }] = matches;
2182
+ return Object.freeze({
2183
+ id,
2184
+ match: "prefix",
2185
+ presence,
2186
+ readable: presence === SESSION_PRESENCE.PRESENT,
2187
+ });
2188
+ }
2189
+
2190
+ /** Resolve an exact id or one unambiguous prefix without reading transcripts. */
2191
+ export function resolveSessionId(input) {
2192
+ const authority = resolveSessionAuthority(input);
2193
+ return authority?.readable ? authority.id : null;
1378
2194
  }
1379
2195
 
1380
2196
  export { TRANSCRIPT_CHAIN_STATUS };
@@ -1394,11 +2210,17 @@ export function startSession(sessionId, meta = {}) {
1394
2210
  }
1395
2211
 
1396
2212
  export function appendUserMessage(sessionId, content) {
1397
- appendEvent(sessionId, "user_message", { role: "user", content });
2213
+ return appendEvent(sessionId, "user_message", {
2214
+ role: "user",
2215
+ content,
2216
+ });
1398
2217
  }
1399
2218
 
1400
2219
  export function appendAssistantMessage(sessionId, content) {
1401
- appendEvent(sessionId, "assistant_message", { role: "assistant", content });
2220
+ return appendEvent(sessionId, "assistant_message", {
2221
+ role: "assistant",
2222
+ content,
2223
+ });
1402
2224
  }
1403
2225
 
1404
2226
  function wsTurnLifecycleResult(snapshot, overrides = {}) {
@@ -1757,6 +2579,16 @@ export function appendCompactEventIfMessagesMatch(
1757
2579
 
1758
2580
  export function readEvents(sessionId) {
1759
2581
  if (isUnsafeSessionId(sessionId)) return []; // traversal id → treat as empty
2582
+ const presence = getSessionPresence(sessionId);
2583
+ if (presence === SESSION_PRESENCE.MISSING_TRANSCRIPT) {
2584
+ throw unverifiedTranscriptError(sessionId, missingTranscriptVerification());
2585
+ }
2586
+ if (presence === SESSION_PRESENCE.CONFLICT) {
2587
+ throw unverifiedTranscriptError(
2588
+ sessionId,
2589
+ transcriptAnchorMismatchVerification(),
2590
+ );
2591
+ }
1760
2592
  const filePath = sessionPath(sessionId);
1761
2593
  if (!existsSync(filePath)) return [];
1762
2594
 
@@ -1782,22 +2614,110 @@ function unverifiedTranscriptError(sessionId, verification) {
1782
2614
  return error;
1783
2615
  }
1784
2616
 
1785
- function assertVerifiedTranscriptAnchor(sessionId, verification) {
2617
+ function missingTranscriptVerification() {
2618
+ return Object.freeze({
2619
+ status: "missing",
2620
+ reason: "persisted session witness exists without its transcript",
2621
+ lastHash: null,
2622
+ chainedEvents: 0,
2623
+ malformedLines: 0,
2624
+ truncatedTail: false,
2625
+ });
2626
+ }
2627
+
2628
+ function transcriptAnchorMismatchVerification(lastHash = null) {
2629
+ return Object.freeze({
2630
+ status: "anchor-mismatch",
2631
+ reason: "persisted session witness does not match its transcript",
2632
+ lastHash,
2633
+ chainedEvents: 0,
2634
+ malformedLines: 0,
2635
+ truncatedTail: false,
2636
+ });
2637
+ }
2638
+
2639
+ function readSessionPersistenceWitness(sessionId) {
2640
+ return readSessionMeta(getSessionsDir(), sessionId);
2641
+ }
2642
+
2643
+ function hasLiveSessionWitness(sessionId) {
2644
+ const witness = readSessionPersistenceWitness(sessionId);
2645
+ if (witness !== null && witness?.deleted !== true) return true;
2646
+ return readSessionAntiRollbackAnchor(sessionId)?.status === "live";
2647
+ }
2648
+
2649
+ function assertLocalVerifiedTranscriptAnchor(
2650
+ sessionId,
2651
+ verification,
2652
+ physicalState = null,
2653
+ ) {
1786
2654
  const meta = readSessionMeta(getSessionsDir(), sessionId);
2655
+ let physicalAnchorMatches = true;
2656
+ let currentPhysicalState = physicalState;
2657
+ if (meta?.transcript) {
2658
+ try {
2659
+ currentPhysicalState ||= readPhysicalTranscriptState(
2660
+ sessionPath(sessionId),
2661
+ );
2662
+ physicalAnchorMatches = samePhysicalTranscriptState(
2663
+ meta.transcript,
2664
+ currentPhysicalState,
2665
+ );
2666
+ } catch {
2667
+ physicalAnchorMatches = false;
2668
+ }
2669
+ }
1787
2670
  const trustedStatus =
1788
2671
  verification.status === TRANSCRIPT_CHAIN_STATUS.VERIFIED;
2672
+ const headMatches = meta?.last_hash === verification.lastHash;
2673
+ const eventCountMatches =
2674
+ Number(meta?.event_count) === verification.chainedEvents;
1789
2675
  const anchoredHead =
1790
2676
  meta?.deleted !== true &&
1791
- meta?.last_hash === verification.lastHash &&
1792
- Number(meta?.event_count) === verification.chainedEvents;
2677
+ headMatches &&
2678
+ eventCountMatches &&
2679
+ physicalAnchorMatches;
1793
2680
  if (
1794
2681
  !trustedStatus ||
1795
2682
  !anchoredHead ||
1796
2683
  verification.malformedLines > 0 ||
1797
2684
  verification.truncatedTail
1798
2685
  ) {
1799
- throw unverifiedTranscriptError(sessionId, verification);
2686
+ const error = unverifiedTranscriptError(sessionId, verification);
2687
+ error.anchor = Object.freeze({
2688
+ metadataPresent: meta !== null,
2689
+ metadataDeleted: meta?.deleted === true,
2690
+ headMatches,
2691
+ eventCountMatches,
2692
+ physicalAnchorMatches,
2693
+ metadataHeadHash: meta?.last_hash ?? null,
2694
+ metadataEventCount: Number.isSafeInteger(Number(meta?.event_count))
2695
+ ? Number(meta.event_count)
2696
+ : null,
2697
+ metadataTranscript: meta?.transcript ?? null,
2698
+ currentTranscript: currentPhysicalState ?? null,
2699
+ });
2700
+ throw error;
1800
2701
  }
2702
+ return meta;
2703
+ }
2704
+
2705
+ function assertVerifiedTranscriptAnchor(
2706
+ sessionId,
2707
+ verification,
2708
+ physicalState = null,
2709
+ ) {
2710
+ const meta = assertLocalVerifiedTranscriptAnchor(
2711
+ sessionId,
2712
+ verification,
2713
+ physicalState,
2714
+ );
2715
+ publishSessionAntiRollbackWitness(
2716
+ sessionId,
2717
+ meta,
2718
+ sessionPath(sessionId),
2719
+ "live",
2720
+ );
1801
2721
  }
1802
2722
 
1803
2723
  /**
@@ -1812,8 +2732,8 @@ function assertVerifiedTranscriptAnchor(sessionId, verification) {
1812
2732
  *
1813
2733
  * This removes the mandatory all-event array from resume projections. It does
1814
2734
  * 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.
2735
+ * transcript remains O(N). The local sidecar is checked against an independent
2736
+ * machine-local anti-rollback witness; it is not itself that witness.
1817
2737
  */
1818
2738
  export function readVerifiedProjection(
1819
2739
  sessionId,
@@ -1847,6 +2767,16 @@ export function readVerifiedProjection(
1847
2767
  );
1848
2768
  }
1849
2769
  if (!existsSync(filePath)) {
2770
+ const presence = getSessionPresence(sessionId);
2771
+ if (presence === SESSION_PRESENCE.TOMBSTONED) {
2772
+ throw sessionDeletedError(sessionId);
2773
+ }
2774
+ if (presence === SESSION_PRESENCE.MISSING_TRANSCRIPT) {
2775
+ throw unverifiedTranscriptError(
2776
+ sessionId,
2777
+ missingTranscriptVerification(),
2778
+ );
2779
+ }
1850
2780
  return projection.finish(
1851
2781
  Object.freeze({
1852
2782
  headHash: null,
@@ -1901,6 +2831,112 @@ export function readVerifiedEvents(sessionId) {
1901
2831
  });
1902
2832
  }
1903
2833
 
2834
+ /**
2835
+ * Return the exact JSONL bytes only after the transcript and its persisted
2836
+ * anchor verify under the canonical writer lock. This is the export/mirror
2837
+ * boundary; derived consumers must never copy a merely parseable raw path.
2838
+ */
2839
+ export function readVerifiedTranscriptBytes(sessionId) {
2840
+ if (isUnsafeSessionId(sessionId)) {
2841
+ throw unverifiedTranscriptError(
2842
+ sessionId,
2843
+ transcriptAnchorMismatchVerification(),
2844
+ );
2845
+ }
2846
+ const filePath = sessionPath(sessionId);
2847
+ return withFileLock(
2848
+ filePath,
2849
+ () => {
2850
+ const presence = getSessionPresence(sessionId);
2851
+ if (presence === SESSION_PRESENCE.MISSING_TRANSCRIPT) {
2852
+ throw unverifiedTranscriptError(
2853
+ sessionId,
2854
+ missingTranscriptVerification(),
2855
+ );
2856
+ }
2857
+ if (presence !== SESSION_PRESENCE.PRESENT) {
2858
+ throw unverifiedTranscriptError(
2859
+ sessionId,
2860
+ transcriptAnchorMismatchVerification(),
2861
+ );
2862
+ }
2863
+ return withTranscriptFileParent(
2864
+ filePath,
2865
+ ({ canonicalPath, parentDevice }) =>
2866
+ readVerifiedTranscriptBytesAtPath(
2867
+ sessionId,
2868
+ canonicalPath,
2869
+ parentDevice,
2870
+ ),
2871
+ );
2872
+ },
2873
+ {
2874
+ failIfUnavailable: true,
2875
+ timeoutMs: 30_000,
2876
+ retryMs: 1,
2877
+ maxRetryMs: 8,
2878
+ retryJitterMs: 4,
2879
+ yieldAfterReleaseMs: 2,
2880
+ },
2881
+ );
2882
+ }
2883
+
2884
+ function readVerifiedTranscriptBytesAtPath(sessionId, filePath, parentDevice) {
2885
+ let flags = fsConstants.O_RDONLY;
2886
+ if (typeof fsConstants.O_NOFOLLOW === "number") {
2887
+ flags |= fsConstants.O_NOFOLLOW;
2888
+ }
2889
+ const fd = openSync(filePath, flags);
2890
+ try {
2891
+ const beforeStats = fstatSync(fd, { bigint: true });
2892
+ const before = physicalTranscriptStateFromStats(beforeStats);
2893
+ const publishedBeforeStats = readPhysicalTranscriptStats(filePath);
2894
+ const publishedBefore =
2895
+ physicalTranscriptStateFromStats(publishedBeforeStats);
2896
+ const descriptorMatchesPublished =
2897
+ parentDevice === null
2898
+ ? samePhysicalTranscriptState(before, publishedBefore)
2899
+ : samePathHandleStableFileIdentity(
2900
+ publishedBeforeStats,
2901
+ beforeStats,
2902
+ parentDevice,
2903
+ ) && samePhysicalTranscriptContentState(before, publishedBefore);
2904
+ if (!descriptorMatchesPublished) {
2905
+ throw transcriptIdentityError(
2906
+ "not-committed",
2907
+ "the read descriptor does not match the canonical path",
2908
+ );
2909
+ }
2910
+ const text = readFileSync(fd, "utf8");
2911
+ const afterStats = fstatSync(fd, { bigint: true });
2912
+ const after = physicalTranscriptStateFromStats(afterStats);
2913
+ const publishedStats = readPhysicalTranscriptStats(filePath);
2914
+ const published = physicalTranscriptStateFromStats(publishedStats);
2915
+ const publishedStillMatches =
2916
+ parentDevice === null
2917
+ ? samePhysicalTranscriptState(after, published)
2918
+ : samePathHandleStableFileIdentity(
2919
+ publishedStats,
2920
+ afterStats,
2921
+ parentDevice,
2922
+ ) && samePhysicalTranscriptContentState(after, published);
2923
+ if (!samePhysicalTranscriptState(before, after) || !publishedStillMatches) {
2924
+ throw transcriptIdentityError(
2925
+ "unknown",
2926
+ "the transcript changed while verified bytes were read",
2927
+ );
2928
+ }
2929
+ const verification = {
2930
+ ...verifyTranscriptText(text),
2931
+ lastHash: latestChainHash(text),
2932
+ };
2933
+ assertVerifiedTranscriptAnchor(sessionId, verification, published);
2934
+ return text;
2935
+ } finally {
2936
+ closeSync(fd);
2937
+ }
2938
+ }
2939
+
1904
2940
  /**
1905
2941
  * Read the active context only after transcript and sidecar-anchor validation.
1906
2942
  * Unlike the compatibility `rebuildMessages()` reader, this is allowed to
@@ -1956,30 +2992,29 @@ export function readVerifiedWsTurnState(sessionId, requestId, options = {}) {
1956
2992
  }
1957
2993
 
1958
2994
  /**
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.
2995
+ * Return the newest event matching a type/predicate after authenticating the
2996
+ * complete transcript and sidecar anchor. Heap remains bounded to one event;
2997
+ * the forward integrity pass is intentionally O(N).
1962
2998
  */
1963
2999
  export function findLatestEvent(sessionId, type, predicate = null) {
1964
3000
  if (isUnsafeSessionId(sessionId)) return null;
1965
- const filePath = sessionPath(sessionId);
1966
- if (!existsSync(filePath)) return null;
1967
3001
  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;
3002
+ return readVerifiedProjection(sessionId, () => {
3003
+ let latest = null;
3004
+ return {
3005
+ accept(event) {
3006
+ const typeMatches = wanted
3007
+ ? wanted.has(event?.type)
3008
+ : type == null || event?.type === type;
3009
+ if (!typeMatches) return;
3010
+ if (typeof predicate === "function" && !predicate(event)) return;
3011
+ latest = event;
3012
+ },
3013
+ finish() {
3014
+ return latest;
3015
+ },
3016
+ };
3017
+ });
1983
3018
  }
1984
3019
 
1985
3020
  /**
@@ -2052,6 +3087,16 @@ function rebuildVerifiedMessagesFromFile(filePath, options = {}) {
2052
3087
 
2053
3088
  export function rebuildMessages(sessionId, options = {}) {
2054
3089
  if (isUnsafeSessionId(sessionId)) return [];
3090
+ const presence = getSessionPresence(sessionId);
3091
+ if (presence === SESSION_PRESENCE.MISSING_TRANSCRIPT) {
3092
+ throw unverifiedTranscriptError(sessionId, missingTranscriptVerification());
3093
+ }
3094
+ if (presence === SESSION_PRESENCE.CONFLICT) {
3095
+ throw unverifiedTranscriptError(
3096
+ sessionId,
3097
+ transcriptAnchorMismatchVerification(),
3098
+ );
3099
+ }
2055
3100
  const filePath = sessionPath(sessionId);
2056
3101
  if (!existsSync(filePath)) return [];
2057
3102
  return rebuildMessagesFromFile(filePath, options);
@@ -2081,6 +3126,7 @@ function rebuildSessionMetaUnlocked(dir, sessionId, filePath) {
2081
3126
  // a best-effort projection over all intact events.
2082
3127
  }
2083
3128
  }
3129
+ meta.transcript = readPhysicalTranscriptState(filePath);
2084
3130
  return replaceSessionMeta(dir, meta);
2085
3131
  }
2086
3132
 
@@ -2090,7 +3136,30 @@ function rebuildSessionMeta(sessionId) {
2090
3136
  if (!existsSync(filePath)) return null;
2091
3137
  return withFileLock(
2092
3138
  filePath,
2093
- () => rebuildSessionMetaUnlocked(dir, sessionId, filePath),
3139
+ () => {
3140
+ const verification = verifyTranscriptFile(filePath);
3141
+ const externalAnchor = readSessionAntiRollbackAnchor(sessionId);
3142
+ if (
3143
+ externalAnchor === null &&
3144
+ [
3145
+ TRANSCRIPT_CHAIN_STATUS.LEGACY,
3146
+ TRANSCRIPT_CHAIN_STATUS.PARTIAL,
3147
+ ].includes(verification.status) &&
3148
+ !verification.truncatedTail
3149
+ ) {
3150
+ return rebuildSessionMetaUnlocked(dir, sessionId, filePath);
3151
+ }
3152
+ if (
3153
+ verification.status !== TRANSCRIPT_CHAIN_STATUS.VERIFIED ||
3154
+ verification.malformedLines > 0 ||
3155
+ verification.truncatedTail
3156
+ ) {
3157
+ throw unverifiedTranscriptError(sessionId, verification);
3158
+ }
3159
+ const meta = rebuildSessionMetaUnlocked(dir, sessionId, filePath);
3160
+ assertVerifiedTranscriptAnchor(sessionId, verification);
3161
+ return meta;
3162
+ },
2094
3163
  {
2095
3164
  failIfUnavailable: true,
2096
3165
  timeoutMs: 30_000,
@@ -2115,9 +3184,13 @@ export function listJsonlSessions(options = {}) {
2115
3184
 
2116
3185
  const limit = options.limit || 20;
2117
3186
  const sessionIds = listSessionIds();
3187
+ let indexScan = null;
2118
3188
  let indexed = listIndexedSessions(dir, {
2119
3189
  limit,
2120
3190
  hasSession: (id) => sessionExists(id),
3191
+ onScanComplete: (scan) => {
3192
+ indexScan = scan;
3193
+ },
2121
3194
  });
2122
3195
 
2123
3196
  // First use after upgrade (or a lost/corrupt rebuildable index): stream each
@@ -2126,8 +3199,22 @@ export function listJsonlSessions(options = {}) {
2126
3199
  if (indexed.length < expected) {
2127
3200
  const rebuilt = [];
2128
3201
  for (const id of sessionIds) {
2129
- const meta = readSessionMeta(dir, id);
2130
- rebuilt.push(meta || rebuildSessionMeta(id));
3202
+ // A physical path restored behind a tombstone is a conflict, not a live
3203
+ // list entry. Never re-emit its deleted snapshot as list activity.
3204
+ if (!sessionExists(id)) continue;
3205
+ const meta = readSessionMeta(dir, id) || rebuildSessionMeta(id);
3206
+ if (!meta) continue;
3207
+ const indexedMeta = indexScan?.latestBySessionId?.get(id) || null;
3208
+ if (
3209
+ indexedMeta?.deleted !== true &&
3210
+ indexedMeta?.last_hash === meta.last_hash &&
3211
+ Number(indexedMeta?.updated_at_ms || 0) ===
3212
+ Number(meta.updated_at_ms || 0) &&
3213
+ Number(indexedMeta?.event_count || 0) === Number(meta.event_count || 0)
3214
+ ) {
3215
+ continue;
3216
+ }
3217
+ rebuilt.push(meta);
2131
3218
  }
2132
3219
  // Directory enumeration order is not activity order. Re-append the rebuilt
2133
3220
  // snapshots oldest-first so reverse journal reads preserve the transcript
@@ -2145,6 +3232,58 @@ export function listJsonlSessions(options = {}) {
2145
3232
  return indexed;
2146
3233
  }
2147
3234
 
3235
+ /**
3236
+ * List canonical namespace occupants for recent-session and picker consumers.
3237
+ * Damaged generations are deliberately represented as blocked rows so a
3238
+ * newer missing/conflicting canonical session cannot disappear and cause an
3239
+ * older legacy conversation to be resumed instead. Tombstones fence duplicate
3240
+ * legacy ids but are not resumable choices unless explicitly requested.
3241
+ */
3242
+ export function listSessionAuthoritySummaries(options = {}) {
3243
+ const limit = Math.max(1, Number(options.limit) || 20);
3244
+ const evidenceIds = listSessionEvidenceIds();
3245
+ const readable = new Map(
3246
+ listJsonlSessions({ limit: Math.max(limit, evidenceIds.length) }).map(
3247
+ (item) => [item.id, item],
3248
+ ),
3249
+ );
3250
+ const rows = [];
3251
+ for (const id of evidenceIds) {
3252
+ const presence = getSessionPresence(id);
3253
+ if (
3254
+ presence === SESSION_PRESENCE.ABSENT ||
3255
+ (presence === SESSION_PRESENCE.TOMBSTONED &&
3256
+ options.includeTombstoned !== true)
3257
+ ) {
3258
+ continue;
3259
+ }
3260
+ const item = readable.get(id);
3261
+ const meta = item ? null : readSessionMeta(getSessionsDir(), id);
3262
+ rows.push({
3263
+ ...(item ||
3264
+ publicSessionMeta({
3265
+ ...emptySessionMeta(id),
3266
+ ...(meta || {}),
3267
+ id,
3268
+ })),
3269
+ id,
3270
+ _store: "jsonl",
3271
+ _presence: presence,
3272
+ _blocked: presence !== SESSION_PRESENCE.PRESENT,
3273
+ });
3274
+ }
3275
+ return rows
3276
+ .sort((a, b) => {
3277
+ const delta =
3278
+ (Date.parse(b.updated_at || "") || 0) -
3279
+ (Date.parse(a.updated_at || "") || 0);
3280
+ if (delta !== 0) return delta;
3281
+ if (a._blocked !== b._blocked) return a._blocked ? -1 : 1;
3282
+ return String(b.id).localeCompare(String(a.id));
3283
+ })
3284
+ .slice(0, limit);
3285
+ }
3286
+
2148
3287
  /**
2149
3288
  * Rename a session (gap-analysis 2026-07-11 P1 "命名会话"): appends a
2150
3289
  * `session_rename` event so the hash chain stays intact (no rewrite). The
@@ -2160,25 +3299,90 @@ export function renameSession(sessionId, title) {
2160
3299
  return { id: sessionId, title: normalized.slice(0, 200) };
2161
3300
  }
2162
3301
 
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. */
3302
+ /** Delete the canonical transcript under its writer lock and publish durable
3303
+ * tombstone witnesses so an already-waiting stale writer cannot resurrect the
3304
+ * session after the deletion commits. */
2166
3305
  export function deleteJsonlSession(sessionId) {
2167
3306
  if (isUnsafeSessionId(sessionId)) return false;
2168
3307
  const filePath = sessionPath(sessionId);
2169
- if (!existsSync(filePath)) return false;
2170
- return withFileLock(
3308
+ if (!existsSync(filePath) && !hasLiveSessionWitness(sessionId)) return false;
3309
+ return withSessionHostWriterLock(
3310
+ sessionId,
2171
3311
  filePath,
2172
3312
  () => {
2173
- if (!existsSync(filePath)) return false;
2174
- rmSync(filePath, { force: true });
3313
+ const transcriptExists = existsSync(filePath);
3314
+ let existingMeta = readSessionPersistenceWitness(sessionId);
3315
+ const marker = readSessionTombstoneMarker(getSessionsDir(), sessionId);
3316
+ const externalAnchor = readSessionAntiRollbackAnchor(sessionId);
3317
+ if (
3318
+ !transcriptExists &&
3319
+ (existingMeta === null || existingMeta?.deleted === true)
3320
+ ) {
3321
+ return false;
3322
+ }
3323
+ const deletionWitness =
3324
+ existingMeta?.deleted === true
3325
+ ? existingMeta
3326
+ : sessionAntiRollbackPredecessorWitness(externalAnchor) || marker;
3327
+ if (
3328
+ transcriptExists &&
3329
+ existingMeta === null &&
3330
+ deletionWitness === null
3331
+ ) {
3332
+ const verification = verifyTranscriptFile(filePath);
3333
+ if (
3334
+ verification.status !== TRANSCRIPT_CHAIN_STATUS.VERIFIED ||
3335
+ verification.malformedLines > 0 ||
3336
+ verification.truncatedTail
3337
+ ) {
3338
+ throw unverifiedTranscriptError(sessionId, verification);
3339
+ }
3340
+ rebuildSessionMetaUnlocked(getSessionsDir(), sessionId, filePath);
3341
+ existingMeta = readSessionPersistenceWitness(sessionId);
3342
+ }
3343
+ if (deletionWitness === null) {
3344
+ assertSessionAntiRollbackBeforeWrite(
3345
+ sessionId,
3346
+ existingMeta,
3347
+ filePath,
3348
+ "live",
3349
+ );
3350
+ } else if (existingMeta?.deleted !== true) {
3351
+ existingMeta = replaceSessionMeta(getSessionsDir(), {
3352
+ ...emptySessionMeta(sessionId),
3353
+ id: sessionId,
3354
+ generation: deletionWitness.generation || null,
3355
+ last_hash: deletionWitness.last_hash || null,
3356
+ event_count: Math.max(0, Number(deletionWitness.event_count) || 0),
3357
+ updated_at_ms: Math.max(
3358
+ 0,
3359
+ Number(deletionWitness.deleted_at_ms) || Date.now(),
3360
+ ),
3361
+ deleted_at_ms: Math.max(
3362
+ 0,
3363
+ Number(deletionWitness.deleted_at_ms) || Date.now(),
3364
+ ),
3365
+ deleted: true,
3366
+ transcript: null,
3367
+ });
3368
+ }
3369
+ if (transcriptExists) rmSync(filePath, { force: true });
2175
3370
  try {
2176
- recordSessionDeleted(getSessionsDir(), sessionId);
3371
+ const tombstone = recordSessionDeleted(getSessionsDir(), sessionId);
3372
+ publishSessionAntiRollbackWitness(
3373
+ sessionId,
3374
+ tombstone,
3375
+ filePath,
3376
+ "deleted",
3377
+ );
2177
3378
  } catch (error) {
2178
3379
  // writeMetaAtomic precedes the activity-journal append. If only the
2179
3380
  // rebuildable journal lock/release failed, the durable sidecar still
2180
3381
  // prevents a stale writer from resurrecting this deleted session.
2181
- if (readSessionMeta(getSessionsDir(), sessionId)?.deleted !== true) {
3382
+ if (
3383
+ readSessionMeta(getSessionsDir(), sessionId)?.deleted !== true ||
3384
+ readSessionAntiRollbackAnchor(sessionId)?.status !== "deleted"
3385
+ ) {
2182
3386
  throw error;
2183
3387
  }
2184
3388
  }
@@ -2481,12 +3685,22 @@ export function forkSession(sourceId, options = {}) {
2481
3685
  sourceHeadHash: sourceVerification.lastHash,
2482
3686
  sourceEventCount: sourceVerification.chainedEvents,
2483
3687
  };
3688
+ const forkGenerationId = `generation-${createHash("sha256")
3689
+ .update(`fork-generation\0${newId}`, "utf8")
3690
+ .digest("hex")
3691
+ .slice(0, 32)}`;
2484
3692
  const core = {
2485
3693
  type: "system",
2486
3694
  timestamp: Date.now(),
2487
3695
  data: {
2488
3696
  ...persistedData,
2489
3697
  [SESSION_FORK_AUTHORITY_FIELD]: forkAuthority,
3698
+ [SESSION_GENERATION_AUTHORITY_FIELD]:
3699
+ createSessionGenerationAuthority(
3700
+ newId,
3701
+ null,
3702
+ forkGenerationId,
3703
+ ),
2490
3704
  },
2491
3705
  };
2492
3706
  const hash = computeEventHash(prevHash, core);
@@ -2516,6 +3730,17 @@ export function forkSession(sourceId, options = {}) {
2516
3730
  sourceHeadHash: authority?.sourceHeadHash,
2517
3731
  sourceEventCount: authorityEventCount,
2518
3732
  };
3733
+ const generationAuthority = normalizeSessionGenerationAuthority(
3734
+ target.lineageEvent?.data?.[SESSION_GENERATION_AUTHORITY_FIELD],
3735
+ );
3736
+ const hasGenerationAuthority =
3737
+ target.lineageEvent?.data?.[SESSION_GENERATION_AUTHORITY_FIELD] !==
3738
+ undefined;
3739
+ const generationMatches =
3740
+ !hasGenerationAuthority ||
3741
+ (generationAuthority?.sessionId === newId &&
3742
+ generationAuthority.ordinal === 1 &&
3743
+ generationAuthority.predecessor === null);
2519
3744
  const lineageMatches =
2520
3745
  target.matchingLineageEvents === 1 &&
2521
3746
  target.lineageEvent?.type === "system" &&
@@ -2524,10 +3749,16 @@ export function forkSession(sourceId, options = {}) {
2524
3749
  typeof authority?.sourceHeadHash === "string" &&
2525
3750
  target.lineageEventIndex === creationEventCount &&
2526
3751
  target.lineageEvent?.prevHash === authority.sourceHeadHash &&
3752
+ generationMatches &&
2527
3753
  JSON.stringify(target.lineageEvent?.data) ===
2528
3754
  JSON.stringify({
2529
3755
  ...persistedData,
2530
3756
  [SESSION_FORK_AUTHORITY_FIELD]: expectedAuthority,
3757
+ ...(hasGenerationAuthority
3758
+ ? {
3759
+ [SESSION_GENERATION_AUTHORITY_FIELD]: generationAuthority,
3760
+ }
3761
+ : {}),
2531
3762
  });
2532
3763
  if (
2533
3764
  target.verification.status !== TRANSCRIPT_CHAIN_STATUS.VERIFIED ||
@@ -2689,7 +3920,7 @@ export function createBranchSession({
2689
3920
  provider: typeof cleanMeta.provider === "string" ? cleanMeta.provider : "",
2690
3921
  model: typeof cleanMeta.model === "string" ? cleanMeta.model : "",
2691
3922
  };
2692
- const plannedEvents = [
3923
+ const digestEvents = [
2693
3924
  { type: "session_start", data: startData },
2694
3925
  {
2695
3926
  type: "session_branch",
@@ -2702,19 +3933,19 @@ export function createBranchSession({
2702
3933
  let count = 0;
2703
3934
  for (const m of canonicalMessages) {
2704
3935
  if (m.role === "user") {
2705
- plannedEvents.push({
3936
+ digestEvents.push({
2706
3937
  type: "user_message",
2707
3938
  data: { role: "user", content: m.content },
2708
3939
  });
2709
3940
  count += 1;
2710
3941
  } else if (m.role === "assistant") {
2711
- plannedEvents.push({
3942
+ digestEvents.push({
2712
3943
  type: "assistant_message",
2713
3944
  data: { role: "assistant", content: m.content },
2714
3945
  });
2715
3946
  count += 1;
2716
3947
  } else if (m.role === "system") {
2717
- plannedEvents.push({
3948
+ digestEvents.push({
2718
3949
  type: "system",
2719
3950
  data: encodePersistedMessage(m),
2720
3951
  });
@@ -2724,8 +3955,27 @@ export function createBranchSession({
2724
3955
  // canonical projection above retains only explicitly durable systems.
2725
3956
  }
2726
3957
  const inputDigest = `sha256:${createHash("sha256")
2727
- .update(JSON.stringify(plannedEvents), "utf8")
3958
+ .update(JSON.stringify(digestEvents), "utf8")
2728
3959
  .digest("hex")}`;
3960
+ const branchGenerationId = `generation-${createHash("sha256")
3961
+ .update(`branch-generation\0${branchSessionId}`, "utf8")
3962
+ .digest("hex")
3963
+ .slice(0, 32)}`;
3964
+ const plannedEvents = digestEvents.map((event, index) =>
3965
+ index === 0
3966
+ ? {
3967
+ ...event,
3968
+ data: encodeSessionGenerationData(
3969
+ event.data,
3970
+ createSessionGenerationAuthority(
3971
+ branchSessionId,
3972
+ null,
3973
+ branchGenerationId,
3974
+ ),
3975
+ ),
3976
+ }
3977
+ : event,
3978
+ );
2729
3979
  plannedEvents.push({
2730
3980
  type: "session_branch_complete",
2731
3981
  data: {
@@ -2740,6 +3990,23 @@ export function createBranchSession({
2740
3990
  return withFileLock(
2741
3991
  filePath,
2742
3992
  () => {
3993
+ const presence = getSessionPresence(branchSessionId);
3994
+ if (
3995
+ presence === SESSION_PRESENCE.TOMBSTONED ||
3996
+ (presence === SESSION_PRESENCE.CONFLICT &&
3997
+ readSessionTombstoneMarker(sessionsDir, branchSessionId) !== null)
3998
+ ) {
3999
+ throw sessionDeletedError(branchSessionId);
4000
+ }
4001
+ if (
4002
+ presence === SESSION_PRESENCE.MISSING_TRANSCRIPT ||
4003
+ presence === SESSION_PRESENCE.CONFLICT
4004
+ ) {
4005
+ throw unverifiedTranscriptError(
4006
+ branchSessionId,
4007
+ transcriptAnchorMismatchVerification(),
4008
+ );
4009
+ }
2743
4010
  if (existsSync(filePath)) inspectPhysicalTail(filePath);
2744
4011
  let verification = existsSync(filePath)
2745
4012
  ? verifyTranscriptFile(filePath)
@@ -2761,12 +4028,22 @@ export function createBranchSession({
2761
4028
  : [];
2762
4029
  const prefixMatches = existingEvents
2763
4030
  .slice(0, Math.min(existingEvents.length, plannedEvents.length))
2764
- .every(
2765
- (event, index) =>
4031
+ .every((event, index) => {
4032
+ let plannedData = plannedEvents[index].data;
4033
+ // Existing v1 branches predate generation authority. They remain
4034
+ // idempotently readable; only newly created branches publish it.
4035
+ if (
4036
+ index === 0 &&
4037
+ event?.data?.[SESSION_GENERATION_AUTHORITY_FIELD] === undefined
4038
+ ) {
4039
+ plannedData = { ...plannedData };
4040
+ delete plannedData[SESSION_GENERATION_AUTHORITY_FIELD];
4041
+ }
4042
+ return (
2766
4043
  event.type === plannedEvents[index].type &&
2767
- JSON.stringify(event.data) ===
2768
- JSON.stringify(plannedEvents[index].data),
2769
- );
4044
+ JSON.stringify(event.data) === JSON.stringify(plannedData)
4045
+ );
4046
+ });
2770
4047
  if (!prefixMatches) {
2771
4048
  const error = new Error(
2772
4049
  `Branch session conflicts with its deterministic input: ${branchSessionId}`,
@@ -2821,6 +4098,9 @@ export function createBranchSession({
2821
4098
  }
2822
4099
 
2823
4100
  let prevHash = verification.lastHash;
4101
+ let expectedTranscriptState = existsSync(filePath)
4102
+ ? readPhysicalTranscriptState(filePath)
4103
+ : null;
2824
4104
  for (
2825
4105
  let index = existingEvents.length;
2826
4106
  index < plannedEvents.length;
@@ -2834,17 +4114,13 @@ export function createBranchSession({
2834
4114
  };
2835
4115
  const hash = computeEventHash(prevHash, core);
2836
4116
  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,
4117
+ expectedTranscriptState = appendTranscriptEvent(
4118
+ branchSessionId,
4119
+ planned.type,
2845
4120
  event,
2846
4121
  filePath,
2847
- });
4122
+ expectedTranscriptState,
4123
+ );
2848
4124
  prevHash = hash;
2849
4125
  }
2850
4126
 
@@ -2873,14 +4149,148 @@ export function createBranchSession({
2873
4149
  );
2874
4150
  }
2875
4151
 
4152
+ export const SESSION_PRESENCE = Object.freeze({
4153
+ ABSENT: "absent",
4154
+ PRESENT: "present",
4155
+ MISSING_TRANSCRIPT: "missing-transcript",
4156
+ TOMBSTONED: "tombstoned",
4157
+ CONFLICT: "conflict",
4158
+ });
4159
+
4160
+ export function getSessionPresence(sessionId) {
4161
+ if (isUnsafeSessionId(sessionId)) return SESSION_PRESENCE.ABSENT;
4162
+ const dir = getSessionsDir();
4163
+ const transcriptExists = existsSync(sessionPath(sessionId));
4164
+ const tombstoneMarkerExists = existsSync(
4165
+ join(dir, `${sessionId}${SESSION_TOMBSTONE_MARKER_SUFFIX}`),
4166
+ );
4167
+ const witness = readSessionPersistenceWitness(sessionId);
4168
+ const externalAnchor = readSessionAntiRollbackAnchor(sessionId);
4169
+ if (
4170
+ transcriptExists &&
4171
+ (witness?.deleted === true ||
4172
+ tombstoneMarkerExists ||
4173
+ externalAnchor?.status === "deleted")
4174
+ ) {
4175
+ return SESSION_PRESENCE.CONFLICT;
4176
+ }
4177
+ if (transcriptExists) {
4178
+ if (witness !== null && witness?.deleted !== true) {
4179
+ assertSessionAntiRollbackBeforeWrite(
4180
+ sessionId,
4181
+ witness,
4182
+ sessionPath(sessionId),
4183
+ "live",
4184
+ );
4185
+ }
4186
+ return SESSION_PRESENCE.PRESENT;
4187
+ }
4188
+ if (
4189
+ witness?.deleted === true ||
4190
+ tombstoneMarkerExists ||
4191
+ externalAnchor?.status === "deleted"
4192
+ ) {
4193
+ return SESSION_PRESENCE.TOMBSTONED;
4194
+ }
4195
+ if (witness !== null || externalAnchor?.status === "live") {
4196
+ return SESSION_PRESENCE.MISSING_TRANSCRIPT;
4197
+ }
4198
+ return SESSION_PRESENCE.ABSENT;
4199
+ }
4200
+
4201
+ /** True when a host must treat this id as canonical instead of falling back. */
4202
+ export function sessionHasPersistedEvidence(sessionId) {
4203
+ return [
4204
+ SESSION_PRESENCE.PRESENT,
4205
+ SESSION_PRESENCE.MISSING_TRANSCRIPT,
4206
+ SESSION_PRESENCE.TOMBSTONED,
4207
+ SESSION_PRESENCE.CONFLICT,
4208
+ ].includes(getSessionPresence(sessionId));
4209
+ }
4210
+
4211
+ /** True only for a physical, non-tombstoned transcript path. */
2876
4212
  export function sessionExists(sessionId) {
2877
- if (isUnsafeSessionId(sessionId)) return false; // never resolve a traversal id
2878
- return existsSync(sessionPath(sessionId));
4213
+ return getSessionPresence(sessionId) === SESSION_PRESENCE.PRESENT;
2879
4214
  }
2880
4215
 
2881
4216
  export function getLastSessionId() {
2882
- const sessions = listJsonlSessions({ limit: 1 });
2883
- return sessions.length > 0 ? sessions[0].id : null;
4217
+ // Seed/rebuild missing indexes for physical transcripts first, then include
4218
+ // a live missing-transcript witness so --continue fails closed on the newest
4219
+ // canonical identity instead of silently selecting an older healthy chat.
4220
+ const dir = getSessionsDir();
4221
+ listJsonlSessions({ limit: 1 });
4222
+ const sessions = listIndexedSessions(dir, {
4223
+ limit: 1,
4224
+ hasSession: (id) =>
4225
+ [
4226
+ SESSION_PRESENCE.PRESENT,
4227
+ SESSION_PRESENCE.MISSING_TRANSCRIPT,
4228
+ SESSION_PRESENCE.CONFLICT,
4229
+ ].includes(getSessionPresence(id)),
4230
+ includeDeleted: (id) =>
4231
+ getSessionPresence(id) === SESSION_PRESENCE.CONFLICT,
4232
+ });
4233
+ const candidateRisk = (presence) =>
4234
+ [SESSION_PRESENCE.MISSING_TRANSCRIPT, SESSION_PRESENCE.CONFLICT].includes(
4235
+ presence,
4236
+ )
4237
+ ? 1
4238
+ : 0;
4239
+ let latest =
4240
+ sessions.length > 0
4241
+ ? {
4242
+ id: sessions[0].id,
4243
+ updatedAt: Date.parse(sessions[0].updated_at) || 0,
4244
+ risk: candidateRisk(getSessionPresence(sessions[0].id)),
4245
+ }
4246
+ : null;
4247
+
4248
+ // A stale/corrupt disposable activity journal cannot rediscover a sidecar-
4249
+ // only missing witness. Directory enumeration already occurs in the list
4250
+ // path; normally read only metadata files that have no transcript
4251
+ // counterpart. Durable tombstone namespace witnesses are enumerable, so
4252
+ // restored transcript conflicts are checked without opening every live
4253
+ // sidecar. This remains correct when the disposable activity journal is
4254
+ // absent, corrupt, or a parseable stale prefix.
4255
+ if (existsSync(dir)) {
4256
+ const files = readdirSync(dir);
4257
+ const transcriptIds = new Set(
4258
+ files
4259
+ .filter((file) => file.endsWith(".jsonl"))
4260
+ .map((file) => basename(file, ".jsonl")),
4261
+ );
4262
+ const tombstoneIds = new Set(
4263
+ files
4264
+ .filter((file) => file.endsWith(SESSION_TOMBSTONE_MARKER_SUFFIX))
4265
+ .map((file) => file.slice(0, -SESSION_TOMBSTONE_MARKER_SUFFIX.length)),
4266
+ );
4267
+ for (const file of files) {
4268
+ if (!file.endsWith(".meta.json")) continue;
4269
+ const id = file.slice(0, -".meta.json".length);
4270
+ const hasTranscript = transcriptIds.has(id);
4271
+ if (isUnsafeSessionId(id) || (hasTranscript && !tombstoneIds.has(id))) {
4272
+ continue;
4273
+ }
4274
+ const meta = readSessionMeta(dir, id);
4275
+ const presence = getSessionPresence(id);
4276
+ const isMissing = presence === SESSION_PRESENCE.MISSING_TRANSCRIPT;
4277
+ const isRecoveredConflict = presence === SESSION_PRESENCE.CONFLICT;
4278
+ if (!meta || (!isMissing && !isRecoveredConflict)) continue;
4279
+ const updatedAt = Math.max(0, Number(meta?.updated_at_ms) || 0);
4280
+ const risk = candidateRisk(presence);
4281
+ if (
4282
+ latest === null ||
4283
+ updatedAt > latest.updatedAt ||
4284
+ (updatedAt === latest.updatedAt && risk > latest.risk) ||
4285
+ (updatedAt === latest.updatedAt &&
4286
+ risk === latest.risk &&
4287
+ id > latest.id)
4288
+ ) {
4289
+ latest = { id, updatedAt, risk };
4290
+ }
4291
+ }
4292
+ }
4293
+ return latest?.id || null;
2884
4294
  }
2885
4295
 
2886
4296
  export function migrateLegacySessions(sourceDir, options = {}) {
@@ -3068,7 +4478,8 @@ function performLegacySessionMigration(sourcePath, options) {
3068
4478
  };
3069
4479
  }
3070
4480
 
3071
- if (!options.force && sessionExists(sessionId)) {
4481
+ const existingPresence = getSessionPresence(sessionId);
4482
+ if (!options.force && existingPresence !== SESSION_PRESENCE.ABSENT) {
3072
4483
  return {
3073
4484
  file: sourcePath,
3074
4485
  sessionId,
@@ -3078,8 +4489,17 @@ function performLegacySessionMigration(sourcePath, options) {
3078
4489
  }
3079
4490
 
3080
4491
  if (!options.dryRun) {
3081
- if (options.force && sessionExists(sessionId)) {
3082
- rmSync(sessionPath(sessionId), { force: true });
4492
+ if (
4493
+ options.force &&
4494
+ ![SESSION_PRESENCE.ABSENT, SESSION_PRESENCE.TOMBSTONED].includes(
4495
+ existingPresence,
4496
+ )
4497
+ ) {
4498
+ if (!deleteJsonlSession(sessionId)) {
4499
+ throw new Error(
4500
+ `could not tombstone existing JSONL session before forced migration: ${sessionId}`,
4501
+ );
4502
+ }
3083
4503
  }
3084
4504
  startSession(sessionId, legacy.meta);
3085
4505
  for (const message of legacy.messages) {