chainlesschain 0.162.158 → 0.162.160

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 (219) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/src/assets/web-panel/assets/{AIOps-BwpTJTN_.js → AIOps-CkRHjW8e.js} +1 -1
  4. package/src/assets/web-panel/assets/{ActionButton-BtQOoo3l.js → ActionButton-BP3MeNi2.js} +1 -1
  5. package/src/assets/web-panel/assets/{Analytics-Duf_rXZa.js → Analytics-CjbjQsOr.js} +2 -2
  6. package/src/assets/web-panel/assets/{AppLayout-XQK3wjvB.js → AppLayout-i0adH1M8.js} +5 -5
  7. package/src/assets/web-panel/assets/Artifacts-CCCG2GOU.js +1 -0
  8. package/src/assets/web-panel/assets/{Audit-DNjOGQLh.js → Audit-DrsgvwHC.js} +1 -1
  9. package/src/assets/web-panel/assets/{BackgroundAgents-FH9RAfB5.js → BackgroundAgents-DkXNkbZM.js} +1 -1
  10. package/src/assets/web-panel/assets/{Backup-ZIbrR4-U.js → Backup-DarTWCex.js} +1 -1
  11. package/src/assets/web-panel/assets/{BaseInput-DSt3NaND.js → BaseInput-DaDzhcto.js} +1 -1
  12. package/src/assets/web-panel/assets/{Chat-CY-wvbjv.js → Chat-C4EzGw6V.js} +5 -5
  13. package/src/assets/web-panel/assets/ChatBubbleRenderer-lkavIWMS.js +1 -0
  14. package/src/assets/web-panel/assets/{Checkbox-8JVadE_B.js → Checkbox-gddNgaQi.js} +1 -1
  15. package/src/assets/web-panel/assets/{Codegen-CN83nPYI.js → Codegen-BKldFWf9.js} +1 -1
  16. package/src/assets/web-panel/assets/{Col-xPSvqdN8.js → Col-ChR-brDs.js} +1 -1
  17. package/src/assets/web-panel/assets/{Community-D78cnIjG.js → Community-DlHtZX86.js} +1 -1
  18. package/src/assets/web-panel/assets/{Compact-CSw6Csbp.js → Compact-BAhfcBpC.js} +1 -1
  19. package/src/assets/web-panel/assets/{Compliance-E_HhjcYR.js → Compliance-gIrKkm6m.js} +1 -1
  20. package/src/assets/web-panel/assets/{Cowork-DybeQjmm.js → Cowork-BYnzLBO3.js} +3 -3
  21. package/src/assets/web-panel/assets/{Cron-DS0BJeLY.js → Cron-ByUDIljI.js} +2 -2
  22. package/src/assets/web-panel/assets/{Crosschain-BOwVgkbs.js → Crosschain-DOdNbHFp.js} +1 -1
  23. package/src/assets/web-panel/assets/{DID-DRUr9YuX.js → DID-BJHaNWD2.js} +2 -2
  24. package/src/assets/web-panel/assets/{Dashboard-Cht-yUKW.js → Dashboard-Drm_tClz.js} +2 -2
  25. package/src/assets/web-panel/assets/{Dropdown-BLr_EJ9X.js → Dropdown-CA1WLzGN.js} +1 -1
  26. package/src/assets/web-panel/assets/{EmailListRenderer-Drexuh5W.js → EmailListRenderer-Di1SKjNO.js} +1 -1
  27. package/src/assets/web-panel/assets/{FamilyGuardDashboard-D9Q2Kq63.js → FamilyGuardDashboard-44Ve7ZFe.js} +1 -1
  28. package/src/assets/web-panel/assets/{Federation-C-Z1xI-T.js → Federation-CubPwv_N.js} +1 -1
  29. package/src/assets/web-panel/assets/{FormItemContext-DLHPMkQF.js → FormItemContext-CsUFe5bU.js} +1 -1
  30. package/src/assets/web-panel/assets/GenericCardRenderer-D7xRjPjZ.js +1 -0
  31. package/src/assets/web-panel/assets/{Git-syATcaBt.js → Git-D-Qx9dhO.js} +2 -2
  32. package/src/assets/web-panel/assets/{Governance-CYe_r8Zf.js → Governance-_YMYaU4n.js} +1 -1
  33. package/src/assets/web-panel/assets/{Inference-GWxErB7k.js → Inference-J7Yzh-K_.js} +1 -1
  34. package/src/assets/web-panel/assets/{KnowledgeGraph-qQvVCZoi.js → KnowledgeGraph-D37iI_Mo.js} +1 -1
  35. package/src/assets/web-panel/assets/{Logs-BenAU2W8.js → Logs-oMTSYfGY.js} +2 -2
  36. package/src/assets/web-panel/assets/{MarkdownRenderer-7agE3Or1.js → MarkdownRenderer-BqaPmC0W.js} +1 -1
  37. package/src/assets/web-panel/assets/{Marketplace-D4Jn5fpD.js → Marketplace-DKUqYDaF.js} +1 -1
  38. package/src/assets/web-panel/assets/{McpTools-DtAwoqiQ.js → McpTools-BEv3Gx61.js} +4 -4
  39. package/src/assets/web-panel/assets/{Memory-BI_O50I8.js → Memory-CtA8XD2y.js} +2 -2
  40. package/src/assets/web-panel/assets/{MobileBridge-CQWE2lqx.js → MobileBridge-ug7c1mMj.js} +1 -1
  41. package/src/assets/web-panel/assets/MobileProjects-BpX5g9It.js +1 -0
  42. package/src/assets/web-panel/assets/{Mtc-Dnr16qsn.js → Mtc-WJaMs4i2.js} +2 -2
  43. package/src/assets/web-panel/assets/{MtcAudit-CQ5Xe4-a.js → MtcAudit-DXnK_-ru.js} +2 -2
  44. package/src/assets/web-panel/assets/{Multisig-Bjr8O-wy.js → Multisig-C4Ev7726.js} +3 -3
  45. package/src/assets/web-panel/assets/{NLProgramming-CEe-KirU.js → NLProgramming-BMFb9_zk.js} +1 -1
  46. package/src/assets/web-panel/assets/{Notes-spKJIKF9.js → Notes-Bl93nvBX.js} +4 -4
  47. package/src/assets/web-panel/assets/{NotificationSettings-DRp83LFG.js → NotificationSettings-BN2W5cl6.js} +1 -1
  48. package/src/assets/web-panel/assets/{OrderTableRenderer-h1A0XB2a.js → OrderTableRenderer-Dxc9oN4o.js} +1 -1
  49. package/src/assets/web-panel/assets/{Organization-BlgB0IYg.js → Organization-CAs48637.js} +4 -4
  50. package/src/assets/web-panel/assets/{Overflow-a_35HqZI.js → Overflow-Bhq27Zoc.js} +1 -1
  51. package/src/assets/web-panel/assets/{P2P-CSNHCNPC.js → P2P-D61-2542.js} +2 -2
  52. package/src/assets/web-panel/assets/{PdhVaultBrowser-C8M1JH-1.js → PdhVaultBrowser-C1j4iUxz.js} +5 -5
  53. package/src/assets/web-panel/assets/{Permissions-DmjJi9Ms.js → Permissions-rJ2-fScM.js} +3 -3
  54. package/src/assets/web-panel/assets/{PersonalDataHub-DfuwRyf4.js → PersonalDataHub-DFIlTok6.js} +2 -2
  55. package/src/assets/web-panel/assets/{Pipeline-B2-S4ekE.js → Pipeline-BjIgEWoZ.js} +1 -1
  56. package/src/assets/web-panel/assets/{Privacy-B1dOiBLC.js → Privacy-BQoH5wcX.js} +1 -1
  57. package/src/assets/web-panel/assets/{ProjectInit-_pNmc-DX.js → ProjectInit-CYR_-_Ub.js} +2 -2
  58. package/src/assets/web-panel/assets/{ProjectSettings-DmwG2Ii1.js → ProjectSettings-nkatCq-9.js} +2 -2
  59. package/src/assets/web-panel/assets/Projects-SS9uZKCl.js +1 -0
  60. package/src/assets/web-panel/assets/{Providers-C6HSjg1d.js → Providers-BnKM9fsF.js} +1 -1
  61. package/src/assets/web-panel/assets/{QrScannerModal-CrDmS7l1.js → QrScannerModal-Cfe82dbz.js} +1 -1
  62. package/src/assets/web-panel/assets/{QuickAsk-CfKvWt-x.js → QuickAsk-r1RbPufV.js} +1 -1
  63. package/src/assets/web-panel/assets/{Recommend-DMa6Kuzv.js → Recommend-CjCbM2WE.js} +1 -1
  64. package/src/assets/web-panel/assets/{RemoteSession-DrktUo5x.js → RemoteSession-BuMGPH6V.js} +2 -2
  65. package/src/assets/web-panel/assets/{Reputation-BTPQ1vjL.js → Reputation-Bmj3NY1F.js} +1 -1
  66. package/src/assets/web-panel/assets/{Row-B6Q7QkMG.js → Row-BIsNcE_1.js} +1 -1
  67. package/src/assets/web-panel/assets/{RssFeed-B1IJhAmX.js → RssFeed-BObmojD2.js} +3 -3
  68. package/src/assets/web-panel/assets/{Search-xbea1kGD.js → Search-BRmbkQNS.js} +1 -1
  69. package/src/assets/web-panel/assets/{Security-7rs0cO3F.js → Security-_rF3DRpY.js} +2 -2
  70. package/src/assets/web-panel/assets/{Services-0fjixK0S.js → Services-D0If9ZV4.js} +2 -2
  71. package/src/assets/web-panel/assets/{Skeleton-B_rVq9Cu.js → Skeleton-o7zIksUg.js} +1 -1
  72. package/src/assets/web-panel/assets/{Skills-C5uM90b9.js → Skills-oUR7zi9u.js} +1 -1
  73. package/src/assets/web-panel/assets/{Sla-U4pYCIZH.js → Sla-fqKyGi03.js} +1 -1
  74. package/src/assets/web-panel/assets/{SpeechSettings-Cdz4yTWx.js → SpeechSettings-B1wZ_gxx.js} +1 -1
  75. package/src/assets/web-panel/assets/{SyncSettings-Me3aetHv.js → SyncSettings-B3qJWYXq.js} +2 -2
  76. package/src/assets/web-panel/assets/{Tasks-Cf6aNkL_.js → Tasks-BH5_dUfr.js} +1 -1
  77. package/src/assets/web-panel/assets/{Templates-CYatk4tU.js → Templates-CCIVpjRP.js} +1 -1
  78. package/src/assets/web-panel/assets/{Tenant-DIMKLw7o.js → Tenant-2GZtTGnB.js} +1 -1
  79. package/src/assets/web-panel/assets/{Terminal-C_cp-uSJ.js → Terminal-BlMkfs_u.js} +2 -2
  80. package/src/assets/web-panel/assets/{TimelineRenderer-cUPVNADp.js → TimelineRenderer-U6hi5OMc.js} +1 -1
  81. package/src/assets/web-panel/assets/{Tokens-CiaEIXVf.js → Tokens-C-_BerGJ.js} +1 -1
  82. package/src/assets/web-panel/assets/{Trigger-DB1WU5lQ.js → Trigger-DaKzBgN3.js} +1 -1
  83. package/src/assets/web-panel/assets/{Trust-Cfht9Ijy.js → Trust-CLvcsco3.js} +1 -1
  84. package/src/assets/web-panel/assets/{UkeySign-K3P4Vt9S.js → UkeySign-BFn_Y1zZ.js} +1 -1
  85. package/src/assets/web-panel/assets/{VideoEditing-DWjhjXRh.js → VideoEditing-B0V18dLT.js} +1 -1
  86. package/src/assets/web-panel/assets/{Wallet-D9-Q1Kan.js → Wallet-BbmP7Imu.js} +4 -4
  87. package/src/assets/web-panel/assets/{WebAuthn-BDszy-oV.js → WebAuthn-Dewj_0kt.js} +5 -5
  88. package/src/assets/web-panel/assets/{WorkflowEditor-DIhij4rA.js → WorkflowEditor-DOxz3Lwa.js} +1 -1
  89. package/src/assets/web-panel/assets/{chat-D7VCiKbh.js → chat-B1OIpZnh.js} +1 -1
  90. package/src/assets/web-panel/assets/{colors-BvOd9pVn.js → colors-CSGNVdri.js} +1 -1
  91. package/src/assets/web-panel/assets/{compact-item-DoqXBiAO.js → compact-item-8S0vcg0u.js} +1 -1
  92. package/src/assets/web-panel/assets/{createContext-CrKRzUtp.js → createContext-vZyc27TN.js} +1 -1
  93. package/src/assets/web-panel/assets/devWarning-DYEZcY55.js +1 -0
  94. package/src/assets/web-panel/assets/{hasIn-BWAev4WC.js → hasIn-DWw3-UhA.js} +1 -1
  95. package/src/assets/web-panel/assets/{index-DvDOpsJB.js → index-B4pP2ycr.js} +1 -1
  96. package/src/assets/web-panel/assets/{index-oY45XrkG.js → index-BDHyovnK.js} +1 -1
  97. package/src/assets/web-panel/assets/{index-CmJ6xKBO.js → index-BKEmQQvR.js} +1 -1
  98. package/src/assets/web-panel/assets/{index-BoYrS_L2.js → index-BQssf0EQ.js} +1 -1
  99. package/src/assets/web-panel/assets/{index-ww1QtLnX.js → index-BZnauCqN.js} +1 -1
  100. package/src/assets/web-panel/assets/{index-BWWsaimP.js → index-BdyokEau.js} +1 -1
  101. package/src/assets/web-panel/assets/{index-CoD31Ndm.js → index-BeBv896m.js} +1 -1
  102. package/src/assets/web-panel/assets/{index-DlGu7As6.js → index-BjC-TrOb.js} +1 -1
  103. package/src/assets/web-panel/assets/{index-zH-tuuGZ.js → index-BkWd6YL7.js} +1 -1
  104. package/src/assets/web-panel/assets/{index-CEXfKSiI.js → index-BoIPHP4R.js} +1 -1
  105. package/src/assets/web-panel/assets/{index-CEPHMSih.js → index-BuB5P0TR.js} +1 -1
  106. package/src/assets/web-panel/assets/{index-PDxGjKRu.js → index-BwKrTVV0.js} +1 -1
  107. package/src/assets/web-panel/assets/{index-BkgnGX7v.js → index-C6r27A-o.js} +1 -1
  108. package/src/assets/web-panel/assets/{index-DV-Rfmfx.js → index-CB2MOS_8.js} +3 -3
  109. package/src/assets/web-panel/assets/{index-KR1-Em9C.js → index-CFQWZvak.js} +1 -1
  110. package/src/assets/web-panel/assets/{index-8g5TJCZ3.js → index-CI3slBKI.js} +1 -1
  111. package/src/assets/web-panel/assets/{index-BCyP071m.js → index-CJgKP2cm.js} +1 -1
  112. package/src/assets/web-panel/assets/{index-CSb-tnIo.js → index-CLPr2vC_.js} +1 -1
  113. package/src/assets/web-panel/assets/{index-1poMpMU0.js → index-CbZqw7pg.js} +1 -1
  114. package/src/assets/web-panel/assets/{index-BZGk8AbZ.js → index-CfEvacz7.js} +1 -1
  115. package/src/assets/web-panel/assets/{index-BXyl8O23.js → index-CwXBblm-.js} +1 -1
  116. package/src/assets/web-panel/assets/{index-BHOcVYrx.js → index-D1i_8oop.js} +1 -1
  117. package/src/assets/web-panel/assets/{index-Bq_ctRpz.js → index-D8qXVfoV.js} +1 -1
  118. package/src/assets/web-panel/assets/{index-gmxqu5Kk.js → index-DKwl1AkQ.js} +1 -1
  119. package/src/assets/web-panel/assets/{index-D23L1V76.js → index-DLAvUtOH.js} +1 -1
  120. package/src/assets/web-panel/assets/{index-CDGLdn12.js → index-DLgkAhEE.js} +1 -1
  121. package/src/assets/web-panel/assets/{index-YGDy7nNq.js → index-DPgY5GLy.js} +1 -1
  122. package/src/assets/web-panel/assets/{index-Cx7M7PSF.js → index-Dc40pX3F.js} +1 -1
  123. package/src/assets/web-panel/assets/{index-JzUmJMvn.js → index-DcfLXAW7.js} +1 -1
  124. package/src/assets/web-panel/assets/{index-HwHdTusY.js → index-DeCS12Mh.js} +1 -1
  125. package/src/assets/web-panel/assets/{index-B30NvR21.js → index-Dg0Izipr.js} +1 -1
  126. package/src/assets/web-panel/assets/index-DmVFfcNR.js +1 -0
  127. package/src/assets/web-panel/assets/{index-B0vGEbJT.js → index-DtHWHJnj.js} +1 -1
  128. package/src/assets/web-panel/assets/{index-Bf9YlbDn.js → index-Q6oyLnKC.js} +1 -1
  129. package/src/assets/web-panel/assets/{index-Bj7EgmOp.js → index-dxocDVD_.js} +1 -1
  130. package/src/assets/web-panel/assets/{index-ISvPZ05r.js → index-t-q3rkVm.js} +1 -1
  131. package/src/assets/web-panel/assets/{index-C5kRmj3P.js → index-tr1x96W9.js} +1 -1
  132. package/src/assets/web-panel/assets/index-x7YyhMUa.js +1 -0
  133. package/src/assets/web-panel/assets/{index-BN9m4Ux2.js → index-xx5M5Rbj.js} +1 -1
  134. package/src/assets/web-panel/assets/{initDefaultProps-XW-OR3mL.js → initDefaultProps-Di5YFId0.js} +1 -1
  135. package/src/assets/web-panel/assets/{motion-CeuEHh20.js → motion-BVSN0aeL.js} +1 -1
  136. package/src/assets/web-panel/assets/{move-Dva79Keg.js → move-DHNiaP2l.js} +1 -1
  137. package/src/assets/web-panel/assets/{mtc-parser-C0B6bD2j.js → mtc-parser-xJPf3gQD.js} +1 -1
  138. package/src/assets/web-panel/assets/{omit-AkZvO-a2.js → omit-C7qzUdkN.js} +1 -1
  139. package/src/assets/web-panel/assets/{pickAttrs-B4r55qGc.js → pickAttrs-pSpjg3Le.js} +1 -1
  140. package/src/assets/web-panel/assets/{placementArrow-BLH-z7YE.js → placementArrow-cs_jL_UC.js} +1 -1
  141. package/src/assets/web-panel/assets/{responsiveObserve-ksEGZ2Wf.js → responsiveObserve-DkS_Zs4r.js} +1 -1
  142. package/src/assets/web-panel/assets/{slide-Cth4OD1Z.js → slide-B5O1Kpxo.js} +1 -1
  143. package/src/assets/web-panel/assets/{statusUtils-9S83ezxw.js → statusUtils-BX02GnZp.js} +1 -1
  144. package/src/assets/web-panel/assets/{styleChecker-7yQmQGZ4.js → styleChecker-EmBYRftd.js} +1 -1
  145. package/src/assets/web-panel/assets/{useFlexGapSupport-DpGnMmPc.js → useFlexGapSupport-ZGzN1cU8.js} +1 -1
  146. package/src/assets/web-panel/assets/{useFs-C4zDZIXH.js → useFs-CfP7M1m7.js} +1 -1
  147. package/src/assets/web-panel/assets/{usePersonalDataHub-CoLICdUZ.js → usePersonalDataHub-CbIPHgNh.js} +1 -1
  148. package/src/assets/web-panel/assets/{vnode-CKyH-35X.js → vnode-7xh0If_O.js} +1 -1
  149. package/src/assets/web-panel/assets/{zoom-DRDGit17.js → zoom-DmitvTR2.js} +1 -1
  150. package/src/assets/web-panel/index.html +1 -1
  151. package/src/command-manifest.json +17 -3
  152. package/src/commands/agent.js +72 -6
  153. package/src/commands/background-session.js +173 -3
  154. package/src/commands/browse.js +86 -0
  155. package/src/commands/cloud.js +232 -0
  156. package/src/commands/doctor.js +61 -6
  157. package/src/commands/mcp.js +107 -0
  158. package/src/commands/plugin.js +63 -1
  159. package/src/commands/routine.js +367 -0
  160. package/src/commands/session.js +396 -0
  161. package/src/data/changelog.json +12 -2
  162. package/src/harness/jsonl-session-store.js +152 -3
  163. package/src/harness/mcp-client.js +50 -0
  164. package/src/harness/session-index.js +295 -0
  165. package/src/harness/session-mirror.js +142 -0
  166. package/src/harness/transcript-integrity.js +182 -0
  167. package/src/index.js +8 -0
  168. package/src/lib/agent-sandbox.js +68 -0
  169. package/src/lib/agents.js +12 -4
  170. package/src/lib/api-key-helper.js +62 -0
  171. package/src/lib/background-agent-supervisor.js +158 -2
  172. package/src/lib/channels/channel-manager.js +126 -0
  173. package/src/lib/channels/telegram-channel.js +112 -0
  174. package/src/lib/channels/webhook-channel.js +140 -0
  175. package/src/lib/chrome-connector.js +386 -0
  176. package/src/lib/cloud/bundle.js +110 -0
  177. package/src/lib/cloud/cloud-client.js +152 -0
  178. package/src/lib/computer-use/authorization.js +173 -0
  179. package/src/lib/computer-use/computer-use-server.js +276 -0
  180. package/src/lib/computer-use/control-backend.js +150 -0
  181. package/src/lib/dependency-install-policy.js +130 -0
  182. package/src/lib/doctor-checkup.js +652 -0
  183. package/src/lib/exit-codes.cjs +77 -0
  184. package/src/lib/headless-manifest.js +175 -0
  185. package/src/lib/hook-runner.cjs +70 -4
  186. package/src/lib/ide-context-redaction.js +130 -0
  187. package/src/lib/ide-context.js +89 -13
  188. package/src/lib/jsonl-session-store.js +6 -0
  189. package/src/lib/llm-config-defaults.js +24 -8
  190. package/src/lib/lsp/lsp-server-registry.js +48 -21
  191. package/src/lib/plugin-runtime/governance.js +174 -0
  192. package/src/lib/pr-link-ledger.js +221 -0
  193. package/src/lib/project-mcp-trust.js +87 -0
  194. package/src/lib/routine-store.js +352 -0
  195. package/src/lib/sensitive-file-guard.js +51 -0
  196. package/src/lib/session-store-guard.js +40 -0
  197. package/src/lib/session-usage.js +318 -2
  198. package/src/lib/sub-agent-context.js +30 -3
  199. package/src/repl/agent-repl.js +361 -3
  200. package/src/repl/bg-dashboard.js +355 -0
  201. package/src/repl/fast-mode.js +103 -0
  202. package/src/repl/fullscreen-tui.js +197 -0
  203. package/src/repl/slash-command-registry.js +11 -3
  204. package/src/repl/voice-dictation.js +132 -0
  205. package/src/runtime/agent-core.js +344 -7
  206. package/src/runtime/coding-agent-contract-shared.cjs +107 -2
  207. package/src/runtime/coding-agent-policy.cjs +17 -0
  208. package/src/runtime/headless-runner.js +137 -14
  209. package/src/runtime/headless-stream.js +43 -9
  210. package/src/runtime/mcp-config.js +32 -1
  211. package/src/runtime/policies/agent-policy.js +3 -0
  212. package/src/assets/web-panel/assets/Artifacts-DQvJoQ6E.js +0 -1
  213. package/src/assets/web-panel/assets/ChatBubbleRenderer-BAV9V-t-.js +0 -1
  214. package/src/assets/web-panel/assets/GenericCardRenderer-DxxEdn2r.js +0 -1
  215. package/src/assets/web-panel/assets/MobileProjects-CVtTqgtg.js +0 -1
  216. package/src/assets/web-panel/assets/Projects-COATmHxF.js +0 -1
  217. package/src/assets/web-panel/assets/devWarning-nDB--2xr.js +0 -1
  218. package/src/assets/web-panel/assets/index-B1_pRTtB.js +0 -1
  219. package/src/assets/web-panel/assets/index-TMM9FuW-.js +0 -1
@@ -14,6 +14,12 @@ import {
14
14
  import { join, basename, resolve } from "node:path";
15
15
  import { createHash } from "node:crypto";
16
16
  import { getHomeDir } from "../lib/paths.js";
17
+ import {
18
+ computeEventHash,
19
+ latestChainHash,
20
+ verifyTranscriptText,
21
+ TRANSCRIPT_CHAIN_STATUS,
22
+ } from "./transcript-integrity.js";
17
23
 
18
24
  function getSessionsDir() {
19
25
  const dir = join(getHomeDir(), "sessions");
@@ -54,11 +60,66 @@ export function appendTokenUsage(sessionId, usage) {
54
60
  appendEvent(sessionId, "token_usage", usage || {});
55
61
  }
56
62
 
63
+ // Chain-tail cache: hash of the last chained record per session, so appends
64
+ // stay O(1) instead of re-reading the file. Keyed per process — two processes
65
+ // appending the SAME session concurrently could fork the chain (verify would
66
+ // then flag it), but the store has always assumed a single writer per session.
67
+ const _chainTailCache = new Map();
68
+
69
+ function _resolveChainTail(sessionId, filePath) {
70
+ if (!existsSync(filePath)) {
71
+ // Missing file self-heals a stale cache entry (file deleted externally,
72
+ // or a test tearing down the sessions dir between cases).
73
+ _chainTailCache.set(sessionId, null);
74
+ return null;
75
+ }
76
+ const cached = _chainTailCache.get(sessionId);
77
+ if (cached !== undefined) return cached;
78
+ const tail = latestChainHash(readFileSync(filePath, "utf-8"));
79
+ _chainTailCache.set(sessionId, tail);
80
+ return tail;
81
+ }
82
+
57
83
  export function appendEvent(sessionId, type, data) {
58
- const line = JSON.stringify({ type, timestamp: Date.now(), data }) + "\n";
59
- appendFileSync(sessionPath(sessionId), line, "utf-8");
84
+ const filePath = sessionPath(sessionId);
85
+ const prevHash = _resolveChainTail(sessionId, filePath);
86
+ const core = { type, timestamp: Date.now(), data };
87
+ const hash = computeEventHash(prevHash, core);
88
+ const line = JSON.stringify({ ...core, prevHash, hash }) + "\n";
89
+ appendFileSync(filePath, line, "utf-8");
90
+ _chainTailCache.set(sessionId, hash);
91
+ }
92
+
93
+ /**
94
+ * Verify a session transcript's hash chain (tamper-evidence).
95
+ * Statuses: verified | partial (legacy prefix + valid chain) | legacy
96
+ * (pre-chaining transcript) | tampered | empty — plus not-found / invalid-id.
97
+ */
98
+ export function verifySession(sessionId) {
99
+ if (isUnsafeSessionId(sessionId)) {
100
+ return { sessionId, status: "invalid-id", reason: "invalid session id" };
101
+ }
102
+ const filePath = sessionPath(sessionId);
103
+ if (!existsSync(filePath)) {
104
+ return { sessionId, status: "not-found", reason: "session file not found" };
105
+ }
106
+ return {
107
+ sessionId,
108
+ ...verifyTranscriptText(readFileSync(filePath, "utf-8")),
109
+ };
110
+ }
111
+
112
+ export function verifyAllSessions(options = {}) {
113
+ const dir = getSessionsDir();
114
+ if (!existsSync(dir)) return [];
115
+ return readdirSync(dir)
116
+ .filter((file) => file.endsWith(".jsonl"))
117
+ .slice(0, options.limit || 1000)
118
+ .map((file) => verifySession(basename(file, ".jsonl")));
60
119
  }
61
120
 
121
+ export { TRANSCRIPT_CHAIN_STATUS };
122
+
62
123
  export function startSession(sessionId, meta = {}) {
63
124
  const id =
64
125
  sessionId ||
@@ -85,6 +146,25 @@ export function appendToolCall(sessionId, toolName, args) {
85
146
  appendEvent(sessionId, "tool_call", { tool: toolName, args });
86
147
  }
87
148
 
149
+ /**
150
+ * Compact tool-call record for usage attribution (用量归因): tool name +
151
+ * error flag (+ an optional skill hint for `run_skill`) — deliberately NOT
152
+ * the args, which can carry whole file bodies (write_file content) and would
153
+ * bloat the transcript. Written at tool-result time by the agent drivers so
154
+ * `cc session usage --by tool|mcp` and `cc insights` can aggregate tool use
155
+ * for any persisted session.
156
+ */
157
+ export function appendToolCallCompact(
158
+ sessionId,
159
+ { tool, isError, skill } = {},
160
+ ) {
161
+ appendEvent(sessionId, "tool_call", {
162
+ tool: tool || "?",
163
+ is_error: Boolean(isError),
164
+ ...(skill ? { skill: String(skill) } : {}),
165
+ });
166
+ }
167
+
88
168
  export function appendToolResult(sessionId, toolName, result) {
89
169
  appendEvent(sessionId, "tool_result", { tool: toolName, result });
90
170
  }
@@ -191,10 +271,17 @@ export function listJsonlSessions(options = {}) {
191
271
  const messageCount = events.filter(
192
272
  (e) => e.type === "user_message" || e.type === "assistant_message",
193
273
  ).length;
274
+ // cc session rename appends session_rename events; the LAST one wins.
275
+ let renamedTitle = null;
276
+ for (const e of events) {
277
+ if (e.type === "session_rename" && e.data?.title) {
278
+ renamedTitle = e.data.title;
279
+ }
280
+ }
194
281
 
195
282
  return {
196
283
  id,
197
- title: startEvent?.data?.title || "Untitled",
284
+ title: renamedTitle || startEvent?.data?.title || "Untitled",
198
285
  provider: startEvent?.data?.provider || "",
199
286
  model: startEvent?.data?.model || "",
200
287
  message_count: messageCount,
@@ -216,6 +303,68 @@ export function listJsonlSessions(options = {}) {
216
303
  return files;
217
304
  }
218
305
 
306
+ /**
307
+ * Rename a session (gap-analysis 2026-07-11 P1 "命名会话"): appends a
308
+ * `session_rename` event so the hash chain stays intact (no rewrite). The
309
+ * LAST rename wins when listing/showing.
310
+ */
311
+ export function renameSession(sessionId, title) {
312
+ if (!sessionExists(sessionId)) {
313
+ throw new Error(`Session not found: ${sessionId}`);
314
+ }
315
+ const normalized = String(title || "").trim();
316
+ if (!normalized) throw new Error("A non-empty title is required");
317
+ appendEvent(sessionId, "session_rename", { title: normalized.slice(0, 200) });
318
+ return { id: sessionId, title: normalized.slice(0, 200) };
319
+ }
320
+
321
+ /**
322
+ * Prune old sessions (gap-analysis 2026-07-11 P1 "保留期限"): delete session
323
+ * transcripts whose LAST activity is older than `olderThanDays`, always
324
+ * keeping the newest `keep` (default 10) regardless of age. Dry-run returns
325
+ * the same shape without deleting.
326
+ */
327
+ export function pruneJsonlSessions(options = {}) {
328
+ const olderThanDays = Number(options.olderThanDays);
329
+ if (!Number.isFinite(olderThanDays) || olderThanDays < 0) {
330
+ throw new Error(
331
+ "prune requires --older-than <days> (a non-negative number)",
332
+ );
333
+ }
334
+ const keep = Number.isFinite(Number(options.keep))
335
+ ? Math.max(0, Math.floor(Number(options.keep)))
336
+ : 10;
337
+ const now = typeof options.now === "number" ? options.now : Date.now();
338
+ const cutoff = now - olderThanDays * 24 * 60 * 60 * 1000;
339
+ // listJsonlSessions sorts by last activity DESC — the first `keep` entries
340
+ // survive unconditionally.
341
+ const all = listJsonlSessions({ limit: 100000 });
342
+ const candidates = all.slice(keep).filter((s) => {
343
+ const last = Date.parse(s.updated_at || s.created_at || "");
344
+ return Number.isFinite(last) && last < cutoff;
345
+ });
346
+ const deleted = [];
347
+ for (const s of candidates) {
348
+ if (options.dryRun === true) {
349
+ deleted.push(s.id);
350
+ continue;
351
+ }
352
+ try {
353
+ rmSync(sessionPath(s.id), { force: true });
354
+ _chainTailCache.delete(s.id);
355
+ deleted.push(s.id);
356
+ } catch {
357
+ /* per-file failures never abort the sweep */
358
+ }
359
+ }
360
+ return {
361
+ scanned: all.length,
362
+ kept: all.length - deleted.length,
363
+ deleted,
364
+ dryRun: options.dryRun === true,
365
+ };
366
+ }
367
+
219
368
  export function forkSession(sourceId) {
220
369
  const events = readEvents(sourceId);
221
370
  if (events.length === 0) return null;
@@ -1051,6 +1051,17 @@ export class MCPClient extends EventEmitter {
1051
1051
 
1052
1052
  // Server notification
1053
1053
  if (msg.method) {
1054
+ // tools/resources list_changed (gap 2026-07-11 MCP 生命周期): refetch
1055
+ // the changed list so entry.tools/entry.resources stay live —
1056
+ // `listTools()`, `/mcp` status and callTool routing all see the update.
1057
+ // (The LLM tool array of an in-flight turn is deliberately NOT mutated:
1058
+ // tool-search's prompt-cache stability depends on an append-only,
1059
+ // stable-prefix tool list.)
1060
+ if (msg.method === "notifications/tools/list_changed") {
1061
+ this._refreshServerList(serverName, "tools");
1062
+ } else if (msg.method === "notifications/resources/list_changed") {
1063
+ this._refreshServerList(serverName, "resources");
1064
+ }
1054
1065
  this.emit("notification", {
1055
1066
  server: serverName,
1056
1067
  method: msg.method,
@@ -1059,6 +1070,45 @@ export class MCPClient extends EventEmitter {
1059
1070
  }
1060
1071
  }
1061
1072
 
1073
+ /**
1074
+ * Re-fetch a server's tools / resources list after a `*_list_changed`
1075
+ * notification. Coalesced per server+kind: a burst of notifications folds
1076
+ * into the in-flight refetch plus at most one trailing pass. Best-effort —
1077
+ * a failed refetch keeps the previous list and waits for the next
1078
+ * notification. Emits "tools-changed" / "resources-changed" on update.
1079
+ */
1080
+ async _refreshServerList(serverName, kind) {
1081
+ const entry = this.servers.get(serverName);
1082
+ if (!entry) return;
1083
+ const flags = (entry._listRefresh = entry._listRefresh || {});
1084
+ if (flags[`${kind}Running`]) {
1085
+ flags[`${kind}Dirty`] = true;
1086
+ return;
1087
+ }
1088
+ flags[`${kind}Running`] = true;
1089
+ try {
1090
+ do {
1091
+ flags[`${kind}Dirty`] = false;
1092
+ try {
1093
+ const method = kind === "tools" ? "tools/list" : "resources/list";
1094
+ const result = await this._sendRequest(serverName, method, {});
1095
+ const list =
1096
+ (kind === "tools" ? result?.tools : result?.resources) || [];
1097
+ entry[kind] = list;
1098
+ if (kind === "tools") entry.toolsError = null;
1099
+ this.emit(`${kind}-changed`, {
1100
+ server: serverName,
1101
+ count: list.length,
1102
+ });
1103
+ } catch {
1104
+ break; // keep the previous list; the next notification retries
1105
+ }
1106
+ } while (flags[`${kind}Dirty`]);
1107
+ } finally {
1108
+ flags[`${kind}Running`] = false;
1109
+ }
1110
+ }
1111
+
1062
1112
  /**
1063
1113
  * Answer a server-initiated JSON-RPC request. `roots/list` returns the
1064
1114
  * session's workspace roots (we advertise the roots capability); `ping`
@@ -0,0 +1,295 @@
1
+ /**
2
+ * Session index (gap-2026-07-11 P2#14) — a SQLite index over the append-only
3
+ * JSONL session store.
4
+ *
5
+ * The JSONL files stay the source of truth (append-only, hash-chained,
6
+ * tamper-evident — see jsonl-session-store.js + transcript-integrity.js). But
7
+ * listing/searching them means reading and parsing every file on every call,
8
+ * which does not scale past a few hundred sessions. This index gives O(1) list
9
+ * ordering and indexed content search, and stays in sync incrementally by
10
+ * file mtime+size so a resume/fork/search/verify is cheap.
11
+ *
12
+ * The index is a derived cache: it can be deleted and fully rebuilt from the
13
+ * JSONL at any time (`syncIndex({ force: true })`). Nothing authoritative lives
14
+ * only here.
15
+ *
16
+ * `Database` is injected via `_deps` so tests run against an in-memory SQLite
17
+ * without touching the user's home dir.
18
+ */
19
+
20
+ import {
21
+ existsSync,
22
+ mkdirSync,
23
+ readdirSync,
24
+ statSync,
25
+ readFileSync,
26
+ } from "node:fs";
27
+ import { join, basename } from "node:path";
28
+ import { createRequire } from "node:module";
29
+ import { getHomeDir } from "../lib/paths.js";
30
+ import { readEvents, toIsoSafe } from "./jsonl-session-store.js";
31
+ import { latestChainHash } from "./transcript-integrity.js";
32
+
33
+ const requireCjs = createRequire(import.meta.url);
34
+
35
+ // Lazy so importing this module never forces the native addon to load (matters
36
+ // for fast CLI paths and for environments where better-sqlite3 is unavailable).
37
+ export const _deps = {
38
+ Database: null,
39
+ loadDatabase() {
40
+ if (!_deps.Database) {
41
+ _deps.Database = requireCjs("better-sqlite3");
42
+ }
43
+ return _deps.Database;
44
+ },
45
+ };
46
+
47
+ function sessionsDir() {
48
+ return join(getHomeDir(), "sessions");
49
+ }
50
+
51
+ function indexPath() {
52
+ const dir = getHomeDir();
53
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
54
+ return join(dir, "session-index.db");
55
+ }
56
+
57
+ const SCHEMA = `
58
+ CREATE TABLE IF NOT EXISTS sessions (
59
+ id TEXT PRIMARY KEY,
60
+ title TEXT,
61
+ provider TEXT,
62
+ model TEXT,
63
+ message_count INTEGER,
64
+ event_count INTEGER,
65
+ created_at TEXT,
66
+ updated_at TEXT,
67
+ last_ts INTEGER,
68
+ chain_hash TEXT,
69
+ mtime_ms INTEGER,
70
+ size_bytes INTEGER
71
+ );
72
+ CREATE TABLE IF NOT EXISTS session_content (
73
+ id TEXT PRIMARY KEY,
74
+ text TEXT
75
+ );
76
+ CREATE INDEX IF NOT EXISTS idx_sessions_last_ts ON sessions(last_ts DESC);
77
+ `;
78
+
79
+ /**
80
+ * Open (and migrate) the index database. Caller owns closing it. Pass
81
+ * `{ file: ":memory:" }` for tests.
82
+ */
83
+ export function openIndex({ file } = {}) {
84
+ const Database = _deps.loadDatabase();
85
+ const db = new Database(file || indexPath());
86
+ db.pragma("journal_mode = WAL");
87
+ db.exec(SCHEMA);
88
+ return db;
89
+ }
90
+
91
+ /** Max characters of concatenated message content indexed per session. */
92
+ const CONTENT_CAP = 200_000;
93
+
94
+ function summarizeEvents(id, events) {
95
+ const startEvent = events.find((e) => e.type === "session_start");
96
+ const lastEvent = events[events.length - 1];
97
+ const messageCount = events.filter(
98
+ (e) => e.type === "user_message" || e.type === "assistant_message",
99
+ ).length;
100
+ const parts = [];
101
+ let used = 0;
102
+ for (const e of events) {
103
+ if (e.type !== "user_message" && e.type !== "assistant_message") continue;
104
+ const c = e.data?.content;
105
+ const s = typeof c === "string" ? c : c == null ? "" : JSON.stringify(c);
106
+ if (!s) continue;
107
+ parts.push(s);
108
+ used += s.length;
109
+ if (used >= CONTENT_CAP) break;
110
+ }
111
+ return {
112
+ id,
113
+ title: startEvent?.data?.title || "Untitled",
114
+ provider: startEvent?.data?.provider || "",
115
+ model: startEvent?.data?.model || "",
116
+ message_count: messageCount,
117
+ event_count: events.length,
118
+ created_at: toIsoSafe(startEvent?.timestamp),
119
+ updated_at: toIsoSafe(lastEvent?.timestamp),
120
+ last_ts: lastEvent?.timestamp || 0,
121
+ content: parts.join("\n").slice(0, CONTENT_CAP),
122
+ };
123
+ }
124
+
125
+ const UPSERT_SESSION = `
126
+ INSERT INTO sessions
127
+ (id,title,provider,model,message_count,event_count,created_at,updated_at,last_ts,chain_hash,mtime_ms,size_bytes)
128
+ VALUES (@id,@title,@provider,@model,@message_count,@event_count,@created_at,@updated_at,@last_ts,@chain_hash,@mtime_ms,@size_bytes)
129
+ ON CONFLICT(id) DO UPDATE SET
130
+ title=@title, provider=@provider, model=@model, message_count=@message_count,
131
+ event_count=@event_count, created_at=@created_at, updated_at=@updated_at,
132
+ last_ts=@last_ts, chain_hash=@chain_hash, mtime_ms=@mtime_ms, size_bytes=@size_bytes
133
+ `;
134
+
135
+ /**
136
+ * Index (or re-index) a single session from its JSONL into `db`.
137
+ * @returns {boolean} true if a row was written.
138
+ */
139
+ export function indexOneSession(db, id, { mtimeMs = 0, sizeBytes = 0 } = {}) {
140
+ const events = readEvents(id);
141
+ if (events.length === 0) return false;
142
+ const s = summarizeEvents(id, events);
143
+ const filePath = join(sessionsDir(), `${id}.jsonl`);
144
+ let chainHash = "";
145
+ try {
146
+ chainHash = latestChainHash(readFileSync(filePath, "utf-8")) || "";
147
+ } catch {
148
+ /* chain hash is best-effort */
149
+ }
150
+ db.prepare(UPSERT_SESSION).run({
151
+ id: s.id,
152
+ title: s.title,
153
+ provider: s.provider,
154
+ model: s.model,
155
+ message_count: s.message_count,
156
+ event_count: s.event_count,
157
+ created_at: s.created_at,
158
+ updated_at: s.updated_at,
159
+ last_ts: s.last_ts,
160
+ chain_hash: chainHash,
161
+ mtime_ms: Math.floor(mtimeMs),
162
+ size_bytes: sizeBytes,
163
+ });
164
+ db.prepare(
165
+ "INSERT INTO session_content (id,text) VALUES (?,?) ON CONFLICT(id) DO UPDATE SET text=excluded.text",
166
+ ).run(s.id, s.content);
167
+ return true;
168
+ }
169
+
170
+ /**
171
+ * Incrementally sync the index with the sessions dir. A session is re-indexed
172
+ * only when its file mtime or size changed since last time (or `force`).
173
+ * Deleted JSONL files have their index rows removed.
174
+ * @returns {{ scanned, updated, removed, total }}
175
+ */
176
+ export function syncIndex(db, { force = false } = {}) {
177
+ const dir = sessionsDir();
178
+ let scanned = 0;
179
+ let updated = 0;
180
+ let removed = 0;
181
+ const seen = new Set();
182
+
183
+ if (existsSync(dir)) {
184
+ const known = new Map(
185
+ db
186
+ .prepare("SELECT id, mtime_ms, size_bytes FROM sessions")
187
+ .all()
188
+ .map((r) => [r.id, r]),
189
+ );
190
+ const files = readdirSync(dir).filter((f) => f.endsWith(".jsonl"));
191
+ const tx = db.transaction((entries) => {
192
+ for (const { id, mtimeMs, size } of entries) {
193
+ seen.add(id);
194
+ scanned++;
195
+ const prev = known.get(id);
196
+ if (
197
+ !force &&
198
+ prev &&
199
+ prev.mtime_ms === Math.floor(mtimeMs) &&
200
+ prev.size_bytes === size
201
+ ) {
202
+ continue; // unchanged
203
+ }
204
+ if (indexOneSession(db, id, { mtimeMs, sizeBytes: size })) updated++;
205
+ }
206
+ });
207
+ const entries = [];
208
+ for (const f of files) {
209
+ const id = basename(f, ".jsonl");
210
+ let st;
211
+ try {
212
+ st = statSync(join(dir, f));
213
+ } catch {
214
+ continue;
215
+ }
216
+ entries.push({ id, mtimeMs: st.mtimeMs, size: st.size });
217
+ }
218
+ tx(entries);
219
+
220
+ // Drop rows whose JSONL no longer exists.
221
+ for (const id of known.keys()) {
222
+ if (!seen.has(id)) {
223
+ db.prepare("DELETE FROM sessions WHERE id=?").run(id);
224
+ db.prepare("DELETE FROM session_content WHERE id=?").run(id);
225
+ removed++;
226
+ }
227
+ }
228
+ }
229
+
230
+ const total = db.prepare("SELECT COUNT(*) c FROM sessions").get().c;
231
+ return { scanned, updated, removed, total };
232
+ }
233
+
234
+ const LIST_COLUMNS =
235
+ "id,title,provider,model,message_count,event_count,created_at,updated_at";
236
+
237
+ /** List indexed sessions, newest activity first. */
238
+ export function listSessions(db, { limit = 20 } = {}) {
239
+ return db
240
+ .prepare(
241
+ `SELECT ${LIST_COLUMNS} FROM sessions ORDER BY last_ts DESC, event_count DESC LIMIT ?`,
242
+ )
243
+ .all(Math.max(1, limit | 0));
244
+ }
245
+
246
+ /**
247
+ * Full-text-ish search over session titles and message content (LIKE). Returns
248
+ * matched sessions with a short snippet around the first hit.
249
+ */
250
+ export function searchSessions(db, query, { limit = 20 } = {}) {
251
+ const q = String(query || "").trim();
252
+ if (!q) return [];
253
+ const like = `%${q.replace(/[%_]/g, (m) => "\\" + m)}%`;
254
+ const rows = db
255
+ .prepare(
256
+ `SELECT s.${LIST_COLUMNS.split(",").join(", s.")}, c.text AS _text
257
+ FROM sessions s LEFT JOIN session_content c ON c.id = s.id
258
+ WHERE s.title LIKE ? ESCAPE '\\' OR c.text LIKE ? ESCAPE '\\'
259
+ ORDER BY s.last_ts DESC LIMIT ?`,
260
+ )
261
+ .all(like, like, Math.max(1, limit | 0));
262
+ const needle = q.toLowerCase();
263
+ return rows.map((r) => {
264
+ const text = r._text || "";
265
+ const at = text.toLowerCase().indexOf(needle);
266
+ const snippet =
267
+ at >= 0
268
+ ? text
269
+ .slice(Math.max(0, at - 40), at + q.length + 40)
270
+ .replace(/\s+/g, " ")
271
+ .trim()
272
+ : "";
273
+ const { _text, ...rest } = r;
274
+ return { ...rest, snippet };
275
+ });
276
+ }
277
+
278
+ /** One indexed session row, or null. */
279
+ export function getIndexedSession(db, id) {
280
+ return db.prepare(`SELECT * FROM sessions WHERE id=?`).get(id) || null;
281
+ }
282
+
283
+ /** Index-wide counters for `cc doctor` / `cc session index --stats`. */
284
+ export function indexStats(db) {
285
+ const row = db
286
+ .prepare(
287
+ "SELECT COUNT(*) sessions, COALESCE(SUM(message_count),0) messages, COALESCE(SUM(event_count),0) events FROM sessions",
288
+ )
289
+ .get();
290
+ return {
291
+ sessions: row.sessions,
292
+ messages: row.messages,
293
+ events: row.events,
294
+ };
295
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Session mirror (gap-2026-07-11 P2#14) — a pluggable off-box copy of the JSONL
3
+ * session store for cross-device / cloud-runner / team scenarios.
4
+ *
5
+ * The mirror is one-way derived from the local JSONL (which stays the source of
6
+ * truth): `push(id)` copies a session's bytes out, `pull(id)` fetches them back,
7
+ * `list()` enumerates what the remote holds. Encryption and access control are
8
+ * the driver's concern (configured independently), so a driver may wrap bytes
9
+ * before they leave the box.
10
+ *
11
+ * Drivers implement a tiny interface `{ push(id, bytes), pull(id), list() }`.
12
+ * Two are built in:
13
+ * - `fs` : mirror to a local/mounted directory (reference impl; also the
14
+ * test target). Covers NFS/SMB-mounted shares for free.
15
+ * - `http` : PUT/GET/list against a self-hosted API mirror (injectable fetch).
16
+ * S3 / WebDAV / Redis are additional drivers that plug in behind the same
17
+ * factory; they need real endpoints so they are configured, not bundled.
18
+ *
19
+ * Pure/DI: filesystem and fetch go through injected deps so the factory and the
20
+ * fs driver are unit-testable without network or a real home dir.
21
+ */
22
+
23
+ import {
24
+ existsSync,
25
+ mkdirSync,
26
+ readFileSync,
27
+ writeFileSync,
28
+ readdirSync,
29
+ } from "node:fs";
30
+ import { join, basename } from "node:path";
31
+ import { isUnsafeSessionId } from "./jsonl-session-store.js";
32
+
33
+ /**
34
+ * Build a mirror driver from config. `config.kind` selects the driver:
35
+ * { kind: "fs", dir: "/mnt/backup/sessions" }
36
+ * { kind: "http", baseUrl: "https://mirror.local", token: "…" }
37
+ * Returns null when no mirror is configured (kind falsy / "none").
38
+ */
39
+ export function createMirror(config = {}, deps = {}) {
40
+ const kind = String(config.kind || config.type || "none").toLowerCase();
41
+ if (!kind || kind === "none") return null;
42
+ if (kind === "fs" || kind === "dir" || kind === "directory") {
43
+ return createFsMirror(config, deps);
44
+ }
45
+ if (kind === "http" || kind === "api") {
46
+ return createHttpMirror(config, deps);
47
+ }
48
+ throw new Error(
49
+ `unknown session mirror kind "${kind}" (use fs | http, or wire an s3/webdav/redis driver)`,
50
+ );
51
+ }
52
+
53
+ /** Directory mirror — the reference driver. */
54
+ export function createFsMirror(config = {}, deps = {}) {
55
+ const dir = config.dir || config.path;
56
+ if (!dir) throw new Error("fs mirror requires a `dir`");
57
+ const _existsSync = deps.existsSync || existsSync;
58
+ const _mkdirSync = deps.mkdirSync || mkdirSync;
59
+ const _readFileSync = deps.readFileSync || readFileSync;
60
+ const _writeFileSync = deps.writeFileSync || writeFileSync;
61
+ const _readdirSync = deps.readdirSync || readdirSync;
62
+
63
+ const ensure = () => {
64
+ if (!_existsSync(dir)) _mkdirSync(dir, { recursive: true });
65
+ return dir;
66
+ };
67
+ const fileFor = (id) => {
68
+ if (isUnsafeSessionId(id)) throw new Error(`unsafe session id: ${id}`);
69
+ return join(dir, `${id}.jsonl`);
70
+ };
71
+
72
+ return {
73
+ kind: "fs",
74
+ target: dir,
75
+ async push(id, bytes) {
76
+ ensure();
77
+ _writeFileSync(fileFor(id), bytes, "utf-8");
78
+ return { id, bytes: Buffer.byteLength(bytes) };
79
+ },
80
+ async pull(id) {
81
+ const f = fileFor(id);
82
+ if (!_existsSync(f)) return null;
83
+ return _readFileSync(f, "utf-8");
84
+ },
85
+ async list() {
86
+ if (!_existsSync(dir)) return [];
87
+ return _readdirSync(dir)
88
+ .filter((f) => f.endsWith(".jsonl"))
89
+ .map((f) => basename(f, ".jsonl"));
90
+ },
91
+ };
92
+ }
93
+
94
+ /** HTTP mirror — PUT/GET/list against a self-hosted API. `fetch` injectable. */
95
+ export function createHttpMirror(config = {}, deps = {}) {
96
+ const baseUrl = String(config.baseUrl || config.url || "").replace(
97
+ /\/+$/,
98
+ "",
99
+ );
100
+ if (!baseUrl) throw new Error("http mirror requires a `baseUrl`");
101
+ const token = config.token || null;
102
+ const doFetch = deps.fetch || ((...a) => globalThis.fetch(...a));
103
+ const headers = (extra = {}) => ({
104
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
105
+ ...extra,
106
+ });
107
+ const guard = (id) => {
108
+ if (isUnsafeSessionId(id)) throw new Error(`unsafe session id: ${id}`);
109
+ return encodeURIComponent(id);
110
+ };
111
+
112
+ return {
113
+ kind: "http",
114
+ target: baseUrl,
115
+ async push(id, bytes) {
116
+ const res = await doFetch(`${baseUrl}/sessions/${guard(id)}`, {
117
+ method: "PUT",
118
+ headers: headers({ "Content-Type": "application/x-ndjson" }),
119
+ body: bytes,
120
+ });
121
+ if (!res.ok) throw new Error(`mirror PUT ${id} → HTTP ${res.status}`);
122
+ return { id, bytes: Buffer.byteLength(bytes) };
123
+ },
124
+ async pull(id) {
125
+ const res = await doFetch(`${baseUrl}/sessions/${guard(id)}`, {
126
+ headers: headers(),
127
+ });
128
+ if (res.status === 404) return null;
129
+ if (!res.ok) throw new Error(`mirror GET ${id} → HTTP ${res.status}`);
130
+ return res.text();
131
+ },
132
+ async list() {
133
+ const res = await doFetch(`${baseUrl}/sessions`, { headers: headers() });
134
+ if (!res.ok) throw new Error(`mirror list → HTTP ${res.status}`);
135
+ const data = await res.json();
136
+ const ids = Array.isArray(data) ? data : data?.sessions || [];
137
+ return ids
138
+ .map((x) => (typeof x === "string" ? x : x?.id))
139
+ .filter(Boolean);
140
+ },
141
+ };
142
+ }