chainlesschain 0.162.166 → 0.162.167

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 (175) hide show
  1. package/package.json +1 -1
  2. package/src/assets/web-panel/assets/{AIOps-LbDx7jL6.js → AIOps-BZJrchIA.js} +1 -1
  3. package/src/assets/web-panel/assets/{ActionButton-DnurtK34.js → ActionButton-Bhlrylt7.js} +1 -1
  4. package/src/assets/web-panel/assets/{Analytics-DXSsV1am.js → Analytics-pHtXVQYs.js} +3 -3
  5. package/src/assets/web-panel/assets/{AppLayout-dqRpzNuD.js → AppLayout-ORpuri4R.js} +3 -3
  6. package/src/assets/web-panel/assets/{Artifacts-BwdmYepK.js → Artifacts-eC77yr1_.js} +1 -1
  7. package/src/assets/web-panel/assets/{Audit-oSX0kOUG.js → Audit-B2dZIiqz.js} +1 -1
  8. package/src/assets/web-panel/assets/{BackgroundAgents-C2KxJahT.js → BackgroundAgents-COZiTiwP.js} +1 -1
  9. package/src/assets/web-panel/assets/{Backup-B4TkxWkC.js → Backup-vY_Srl5d.js} +1 -1
  10. package/src/assets/web-panel/assets/{BaseInput-CX5-KxeU.js → BaseInput-Dili5BdM.js} +1 -1
  11. package/src/assets/web-panel/assets/{Chat-O_HzD-PL.js → Chat-EzKBf0XL.js} +6 -6
  12. package/src/assets/web-panel/assets/{ChatBubbleRenderer-Br8saHQA.js → ChatBubbleRenderer-D1CtRAyy.js} +1 -1
  13. package/src/assets/web-panel/assets/{Checkbox-ChepDpC1.js → Checkbox-Beh6BmuW.js} +1 -1
  14. package/src/assets/web-panel/assets/{Codegen-D9z8kc7q.js → Codegen-C3fA6kBU.js} +1 -1
  15. package/src/assets/web-panel/assets/{Col-B-H5eoNw.js → Col-CefMqONH.js} +1 -1
  16. package/src/assets/web-panel/assets/{Community-Ccyp_Kk5.js → Community-CBAxFJtB.js} +1 -1
  17. package/src/assets/web-panel/assets/{Compact-BNvBznp_.js → Compact--flHc-sM.js} +1 -1
  18. package/src/assets/web-panel/assets/{Compliance-D1FjRd8n.js → Compliance-CPeNLvA6.js} +1 -1
  19. package/src/assets/web-panel/assets/{Cowork-CV9B9u-8.js → Cowork-Bv5yv4BU.js} +2 -2
  20. package/src/assets/web-panel/assets/{Cron-BowJ4VIx.js → Cron-C22yMEas.js} +2 -2
  21. package/src/assets/web-panel/assets/{Crosschain-BJc6jzRF.js → Crosschain-Dsb2bcpu.js} +1 -1
  22. package/src/assets/web-panel/assets/{DID-BEp8NV6D.js → DID-DAxei4w0.js} +2 -2
  23. package/src/assets/web-panel/assets/{Dashboard-Dls0B3YB.js → Dashboard-DEUwuVZ8.js} +2 -2
  24. package/src/assets/web-panel/assets/{Dropdown-DQ7zXVtD.js → Dropdown-BVp4si2j.js} +1 -1
  25. package/src/assets/web-panel/assets/{EmailListRenderer-B9q79tOl.js → EmailListRenderer-CyFkybOG.js} +1 -1
  26. package/src/assets/web-panel/assets/{FamilyGuardDashboard-CD8Ie-88.js → FamilyGuardDashboard-BWmmsbVx.js} +1 -1
  27. package/src/assets/web-panel/assets/{Federation-5o7XU5Vf.js → Federation-Cxw27A5d.js} +1 -1
  28. package/src/assets/web-panel/assets/{FormItemContext-DeKbMvOs.js → FormItemContext-Q9XthzL1.js} +1 -1
  29. package/src/assets/web-panel/assets/{GenericCardRenderer-CZiQvz_z.js → GenericCardRenderer-DS5DIdsW.js} +1 -1
  30. package/src/assets/web-panel/assets/{Git-CTvFxVV8.js → Git-BOliHfxP.js} +2 -2
  31. package/src/assets/web-panel/assets/{Governance-DwzFIjHt.js → Governance-CD4oxa-W.js} +1 -1
  32. package/src/assets/web-panel/assets/{Inference-CJBk59c-.js → Inference-CMlcivo8.js} +1 -1
  33. package/src/assets/web-panel/assets/{KnowledgeGraph-DfsaXN2z.js → KnowledgeGraph-BwhERcC4.js} +1 -1
  34. package/src/assets/web-panel/assets/{Logs-DVRYf18S.js → Logs-BjVcZR5P.js} +2 -2
  35. package/src/assets/web-panel/assets/MarkdownRenderer-BfRhXdyG.js +1 -0
  36. package/src/assets/web-panel/assets/{Marketplace-BuIwyE4t.js → Marketplace-DQLU3E2w.js} +1 -1
  37. package/src/assets/web-panel/assets/{McpTools-D3U0o78E.js → McpTools-CeK0XkpB.js} +5 -5
  38. package/src/assets/web-panel/assets/{Memory-QYVmyb3b.js → Memory-DLFkgwaz.js} +2 -2
  39. package/src/assets/web-panel/assets/{MobileBridge-BFgrj6eZ.js → MobileBridge-Bh6ymXyj.js} +1 -1
  40. package/src/assets/web-panel/assets/{MobileProjects-Bp5QB-46.js → MobileProjects-D950tUIe.js} +1 -1
  41. package/src/assets/web-panel/assets/{Mtc-BZQu8TcH.js → Mtc-Bx5x9_2m.js} +4 -4
  42. package/src/assets/web-panel/assets/{MtcAudit-BnFfEkLo.js → MtcAudit-a1-Fexsq.js} +2 -2
  43. package/src/assets/web-panel/assets/{Multisig-iUnNS9Cm.js → Multisig-eaw0X8X1.js} +3 -3
  44. package/src/assets/web-panel/assets/{NLProgramming-DNNz1y9Y.js → NLProgramming-BaNsPV4k.js} +1 -1
  45. package/src/assets/web-panel/assets/{Notes-CZezNYRU.js → Notes-C7pevN2X.js} +3 -3
  46. package/src/assets/web-panel/assets/{NotificationSettings-DBvrplIR.js → NotificationSettings-HDqeoP0r.js} +1 -1
  47. package/src/assets/web-panel/assets/{OrderTableRenderer-CTDtee49.js → OrderTableRenderer-DvzVl5T3.js} +1 -1
  48. package/src/assets/web-panel/assets/{Organization-CCOUJX1R.js → Organization-D_xHDlGW.js} +4 -4
  49. package/src/assets/web-panel/assets/{Overflow-DcZghdk0.js → Overflow-B5sRnueL.js} +1 -1
  50. package/src/assets/web-panel/assets/{P2P-jmddWX9q.js → P2P-C-IRjOPW.js} +2 -2
  51. package/src/assets/web-panel/assets/{PdhVaultBrowser-wfjMcczl.js → PdhVaultBrowser-BqgUPlcU.js} +4 -4
  52. package/src/assets/web-panel/assets/{Permissions-DU1GJjca.js → Permissions-Bm_T1uWw.js} +4 -4
  53. package/src/assets/web-panel/assets/{PersonalDataHub-BzTMzDF9.js → PersonalDataHub-DujDllye.js} +4 -4
  54. package/src/assets/web-panel/assets/{Pipeline-DPmjlpVc.js → Pipeline-DyNPOLIG.js} +1 -1
  55. package/src/assets/web-panel/assets/{Privacy-B6HRLpfK.js → Privacy-CYKTW3iK.js} +1 -1
  56. package/src/assets/web-panel/assets/{ProjectInit-Cyy86AsN.js → ProjectInit-CGf03uZ0.js} +2 -2
  57. package/src/assets/web-panel/assets/{ProjectSettings-Bl_WQxJ5.js → ProjectSettings-DIec4bTI.js} +2 -2
  58. package/src/assets/web-panel/assets/{Projects-Co9yKThA.js → Projects-BY43-bmi.js} +1 -1
  59. package/src/assets/web-panel/assets/{Providers-DLa4NlIs.js → Providers-RrnUmgPk.js} +1 -1
  60. package/src/assets/web-panel/assets/{QrScannerModal-BvzM7q5f.js → QrScannerModal-BBWfqd4y.js} +1 -1
  61. package/src/assets/web-panel/assets/{QuickAsk-BN8yyyUj.js → QuickAsk-BsICSrFp.js} +1 -1
  62. package/src/assets/web-panel/assets/{Recommend-QsVN4w0i.js → Recommend-B8y0b4ys.js} +1 -1
  63. package/src/assets/web-panel/assets/{RemoteSession-tkDXw-TY.js → RemoteSession-0QgdB1Oe.js} +2 -2
  64. package/src/assets/web-panel/assets/{Reputation-DAifkGsy.js → Reputation-DBJGM0YO.js} +1 -1
  65. package/src/assets/web-panel/assets/{Row-D7dZEyak.js → Row-DtWQ5otv.js} +1 -1
  66. package/src/assets/web-panel/assets/{RssFeed-DB5sImTA.js → RssFeed-2zBxHd5H.js} +3 -3
  67. package/src/assets/web-panel/assets/{Search-3py3TO9V.js → Search-BofliSkZ.js} +1 -1
  68. package/src/assets/web-panel/assets/{Security-DhNUfT_k.js → Security-ockRBBEL.js} +3 -3
  69. package/src/assets/web-panel/assets/{Services-DjJAA_5D.js → Services-QyvG7Vd0.js} +2 -2
  70. package/src/assets/web-panel/assets/{Skeleton-u95JjK3n.js → Skeleton-DwbOndX3.js} +1 -1
  71. package/src/assets/web-panel/assets/{Skills-DPo3L0Tz.js → Skills-cHWa63sZ.js} +1 -1
  72. package/src/assets/web-panel/assets/{Sla-DWhkAZV1.js → Sla-BHnTEydQ.js} +1 -1
  73. package/src/assets/web-panel/assets/{SpeechSettings-PUegMtqV.js → SpeechSettings-DE2hrrLb.js} +1 -1
  74. package/src/assets/web-panel/assets/{SyncSettings-BMNYrnrU.js → SyncSettings-CZ9vnbJH.js} +2 -2
  75. package/src/assets/web-panel/assets/Tasks-DWgoAHvM.js +1 -0
  76. package/src/assets/web-panel/assets/{Templates-Dw9Er5DN.js → Templates-DiR3ad4X.js} +1 -1
  77. package/src/assets/web-panel/assets/{Tenant-7P3QIBSo.js → Tenant-NqcW3frV.js} +1 -1
  78. package/src/assets/web-panel/assets/{Terminal-ClPrfWW7.js → Terminal-iYeqRQZ0.js} +2 -2
  79. package/src/assets/web-panel/assets/{TimelineRenderer-DbCd1bDH.js → TimelineRenderer-C5GOoxFz.js} +1 -1
  80. package/src/assets/web-panel/assets/{Tokens-EQofpA0t.js → Tokens-82dz0XdU.js} +1 -1
  81. package/src/assets/web-panel/assets/{Trigger-ChNLLypd.js → Trigger-B3JCNrOi.js} +1 -1
  82. package/src/assets/web-panel/assets/{Trust-dlVGUKs5.js → Trust-BehFYsN_.js} +1 -1
  83. package/src/assets/web-panel/assets/{UkeySign-BXyBUhNF.js → UkeySign-BfOxJjVf.js} +1 -1
  84. package/src/assets/web-panel/assets/{VideoEditing-BE8-68ge.js → VideoEditing-DdITCCPz.js} +1 -1
  85. package/src/assets/web-panel/assets/{Wallet-7ZIJOwNg.js → Wallet-B0EhkCmD.js} +4 -4
  86. package/src/assets/web-panel/assets/{WebAuthn-DgNd2Amr.js → WebAuthn-DJ4i1f3-.js} +3 -3
  87. package/src/assets/web-panel/assets/{WorkflowEditor-Z-Un-9l5.js → WorkflowEditor-Dr9XTqIT.js} +1 -1
  88. package/src/assets/web-panel/assets/{chat-CtVDEkUc.js → chat-Dc2nFyOj.js} +1 -1
  89. package/src/assets/web-panel/assets/{colors-PXtJ2f1B.js → colors-Drw9djBR.js} +1 -1
  90. package/src/assets/web-panel/assets/{compact-item-oN96nuBi.js → compact-item-BdLXbU52.js} +1 -1
  91. package/src/assets/web-panel/assets/{createContext-okCJXQXa.js → createContext-BpQMZiWY.js} +1 -1
  92. package/src/assets/web-panel/assets/devWarning-CnfpDzMr.js +1 -0
  93. package/src/assets/web-panel/assets/{hasIn-pcHm--4o.js → hasIn-CbYwawHf.js} +1 -1
  94. package/src/assets/web-panel/assets/{index-BZKk4Lnm.js → index-B1ANzVc6.js} +1 -1
  95. package/src/assets/web-panel/assets/{index-CQYes2mk.js → index-B3uRI8_y.js} +1 -1
  96. package/src/assets/web-panel/assets/{index-Cc_MkksW.js → index-B5KboakZ.js} +1 -1
  97. package/src/assets/web-panel/assets/{index-CQ1ZzJjN.js → index-B9_u4Qhe.js} +1 -1
  98. package/src/assets/web-panel/assets/{index-Bht9n8Sm.js → index-BAq-4KqY.js} +1 -1
  99. package/src/assets/web-panel/assets/{index-_SaFHNsG.js → index-BBueKSTR.js} +1 -1
  100. package/src/assets/web-panel/assets/{index-jAY55MSx.js → index-BIcEW07n.js} +1 -1
  101. package/src/assets/web-panel/assets/{index-nMqLdwao.js → index-BWUoc9-s.js} +1 -1
  102. package/src/assets/web-panel/assets/{index-BHJIfdCV.js → index-BZxzdAI0.js} +1 -1
  103. package/src/assets/web-panel/assets/{index-CAQktO-6.js → index-BiGDrEt4.js} +1 -1
  104. package/src/assets/web-panel/assets/{index-BjVIZfdA.js → index-Blv7c80V.js} +1 -1
  105. package/src/assets/web-panel/assets/{index-BEtzqJw3.js → index-BmQlot_u.js} +1 -1
  106. package/src/assets/web-panel/assets/{index-D0bfypsF.js → index-Bo_KwJTF.js} +1 -1
  107. package/src/assets/web-panel/assets/{index-C0rfJBU3.js → index-Bu8Vy-mj.js} +1 -1
  108. package/src/assets/web-panel/assets/{index-BuME9k4Y.js → index-C1eo1Qkd.js} +1 -1
  109. package/src/assets/web-panel/assets/{index-D1efSh1X.js → index-C3Alz2QD.js} +1 -1
  110. package/src/assets/web-panel/assets/{index-DBcKBb1E.js → index-CBwQwtsE.js} +1 -1
  111. package/src/assets/web-panel/assets/{index-C4ravjdv.js → index-CJQiSQw7.js} +1 -1
  112. package/src/assets/web-panel/assets/{index-Cs1fGk1Z.js → index-CKmz3Is-.js} +1 -1
  113. package/src/assets/web-panel/assets/{index-BUUJ2WfG.js → index-COoIIMbO.js} +1 -1
  114. package/src/assets/web-panel/assets/index-CYTEB0tb.js +1 -0
  115. package/src/assets/web-panel/assets/index-CgsEp5bm.js +1 -0
  116. package/src/assets/web-panel/assets/{index-DC0bqrqV.js → index-CpmlTfpv.js} +1 -1
  117. package/src/assets/web-panel/assets/{index-Bl6N7ZyX.js → index-D7Ipfs4F.js} +1 -1
  118. package/src/assets/web-panel/assets/{index-vW0lwj0Y.js → index-DHMeEu-P.js} +1 -1
  119. package/src/assets/web-panel/assets/{index-mB1UV7wA.js → index-DKQlF7Pw.js} +1 -1
  120. package/src/assets/web-panel/assets/{index-BDR2mi1p.js → index-DO7LD_1t.js} +1 -1
  121. package/src/assets/web-panel/assets/{index-CK0H3pQW.js → index-DSmZjrFy.js} +1 -1
  122. package/src/assets/web-panel/assets/{index-DtsyPnF5.js → index-DTeqWdAS.js} +1 -1
  123. package/src/assets/web-panel/assets/{index-T3QE8LuV.js → index-DWUN8c01.js} +1 -1
  124. package/src/assets/web-panel/assets/{index-BUTyx-Lk.js → index-D_lkTnZ0.js} +1 -1
  125. package/src/assets/web-panel/assets/{index-5aO2V0V4.js → index-DyU21xVi.js} +1 -1
  126. package/src/assets/web-panel/assets/{index-CWUJlCvK.js → index-GG_MXjSL.js} +3 -3
  127. package/src/assets/web-panel/assets/{index-CopGaCI_.js → index-IG3__pGj.js} +1 -1
  128. package/src/assets/web-panel/assets/{index-BnhHxZul.js → index-UX_Mfyfv.js} +1 -1
  129. package/src/assets/web-panel/assets/{index-CXhezK4d.js → index-VIyNlXPX.js} +1 -1
  130. package/src/assets/web-panel/assets/{index-Rk0X8lVz.js → index-XYe6kBmi.js} +1 -1
  131. package/src/assets/web-panel/assets/{index-DfL10EcN.js → index-_vVucYqC.js} +1 -1
  132. package/src/assets/web-panel/assets/{index-BeAWCGEK.js → index-aHrUfrv1.js} +1 -1
  133. package/src/assets/web-panel/assets/{initDefaultProps-CG1jyiI7.js → initDefaultProps-DybC85E4.js} +1 -1
  134. package/src/assets/web-panel/assets/{motion-C8BYa98D.js → motion-BNNuyDg7.js} +1 -1
  135. package/src/assets/web-panel/assets/{move-D1hqwitW.js → move-Bm8gSjEN.js} +1 -1
  136. package/src/assets/web-panel/assets/{mtc-parser-C2lol8rp.js → mtc-parser-CuHQvzzr.js} +1 -1
  137. package/src/assets/web-panel/assets/{omit-Ct6Zghfi.js → omit-lv4NO7Ng.js} +1 -1
  138. package/src/assets/web-panel/assets/{pickAttrs-CSYbYfdc.js → pickAttrs-BoSMfjHq.js} +1 -1
  139. package/src/assets/web-panel/assets/{placementArrow-WCk8di0z.js → placementArrow-BOMDSvgz.js} +1 -1
  140. package/src/assets/web-panel/assets/{responsiveObserve-CEC8CZ-3.js → responsiveObserve-C064Ubmu.js} +1 -1
  141. package/src/assets/web-panel/assets/{slide-BsXyd7Lq.js → slide-CeqhbKoG.js} +1 -1
  142. package/src/assets/web-panel/assets/{statusUtils-tDKldiUT.js → statusUtils-CunFoYww.js} +1 -1
  143. package/src/assets/web-panel/assets/{styleChecker-BIS2unhx.js → styleChecker-Cxz3wPL7.js} +1 -1
  144. package/src/assets/web-panel/assets/{useFlexGapSupport-yFTVMDUv.js → useFlexGapSupport-DJsIaU2S.js} +1 -1
  145. package/src/assets/web-panel/assets/{useFs-CZYbJwkO.js → useFs-DnAygceJ.js} +1 -1
  146. package/src/assets/web-panel/assets/{usePersonalDataHub-sbEHRLAM.js → usePersonalDataHub-CrEUY7ta.js} +1 -1
  147. package/src/assets/web-panel/assets/{vnode-BVjeSEQK.js → vnode-Cq3rN2eT.js} +1 -1
  148. package/src/assets/web-panel/assets/{zoom-BZKVvEZ6.js → zoom-TMzcOx__.js} +1 -1
  149. package/src/assets/web-panel/index.html +1 -1
  150. package/src/commands/agent.js +75 -0
  151. package/src/commands/session.js +132 -0
  152. package/src/data/changelog.json +7 -2
  153. package/src/harness/jsonl-session-store.js +9 -0
  154. package/src/harness/session-mirror.js +212 -6
  155. package/src/lib/async-hook-queue.cjs +288 -0
  156. package/src/lib/async-hook-supervisor.cjs +52 -0
  157. package/src/lib/doctor-checkup.js +184 -4
  158. package/src/lib/install-command-policy.js +333 -0
  159. package/src/lib/jsonl-session-store.js +1 -0
  160. package/src/lib/lsp/__tests__/lsp-server-registry.test.js +27 -0
  161. package/src/lib/lsp/lsp-server-registry.js +14 -0
  162. package/src/lib/runtime-checkup.js +33 -0
  163. package/src/lib/settings-hook-events.cjs +157 -0
  164. package/src/lib/settings-hooks.cjs +4 -0
  165. package/src/lib/shell-approval.js +47 -1
  166. package/src/repl/add-dir.js +59 -1
  167. package/src/repl/agent-repl.js +85 -3
  168. package/src/runtime/headless-runner.js +69 -1
  169. package/src/runtime/headless-stream.js +22 -0
  170. package/src/runtime/system-prompt.js +39 -12
  171. package/src/assets/web-panel/assets/MarkdownRenderer-BL_WsWZM.js +0 -1
  172. package/src/assets/web-panel/assets/Tasks-CYm_oTJC.js +0 -1
  173. package/src/assets/web-panel/assets/devWarning-BzV6dk1B.js +0 -1
  174. package/src/assets/web-panel/assets/index-BFrAwXHp.js +0 -1
  175. package/src/assets/web-panel/assets/index-DJQFP47u.js +0 -1
@@ -26,10 +26,20 @@ import {
26
26
  readFileSync,
27
27
  writeFileSync,
28
28
  readdirSync,
29
+ unlinkSync,
29
30
  } from "node:fs";
31
+ import {
32
+ scryptSync,
33
+ randomBytes,
34
+ createCipheriv,
35
+ createDecipheriv,
36
+ } from "node:crypto";
30
37
  import { join, basename } from "node:path";
31
38
  import { isUnsafeSessionId } from "./jsonl-session-store.js";
32
39
 
40
+ const CIPHER_VERSION = 1;
41
+ const CIPHER_ALGO = "aes-256-gcm";
42
+
33
43
  /**
34
44
  * Build a mirror driver from config. `config.kind` selects the driver:
35
45
  * { kind: "fs", dir: "/mnt/backup/sessions" }
@@ -39,15 +49,193 @@ import { isUnsafeSessionId } from "./jsonl-session-store.js";
39
49
  export function createMirror(config = {}, deps = {}) {
40
50
  const kind = String(config.kind || config.type || "none").toLowerCase();
41
51
  if (!kind || kind === "none") return null;
52
+ let driver;
42
53
  if (kind === "fs" || kind === "dir" || kind === "directory") {
43
- return createFsMirror(config, deps);
54
+ driver = createFsMirror(config, deps);
55
+ } else if (kind === "http" || kind === "api") {
56
+ driver = createHttpMirror(config, deps);
57
+ } else {
58
+ throw new Error(
59
+ `unknown session mirror kind "${kind}" (use fs | http, or wire an s3/webdav/redis driver)`,
60
+ );
44
61
  }
45
- if (kind === "http" || kind === "api") {
46
- return createHttpMirror(config, deps);
62
+ // When `encryption` (a passphrase) is configured, wrap the driver so bytes are
63
+ // AES-256-GCM encrypted at rest — the mirror target only ever holds ciphertext,
64
+ // and only the run-time passphrase can decrypt it. Absent → raw bytes (the
65
+ // prior behaviour, byte-for-byte).
66
+ const enc = config.encryption || config.encrypt;
67
+ if (enc && (enc.passphrase || enc.secret)) {
68
+ return wrapEncryptedMirror(driver, createMirrorCipher(enc, deps));
47
69
  }
48
- throw new Error(
49
- `unknown session mirror kind "${kind}" (use fs | http, or wire an s3/webdav/redis driver)`,
50
- );
70
+ return driver;
71
+ }
72
+
73
+ /**
74
+ * Passphrase-based AES-256-GCM cipher for mirrored session bytes. scrypt derives
75
+ * a 256-bit key from the passphrase + a per-record random salt; a random 12-byte
76
+ * nonce and the GCM tag give confidentiality + integrity (a tampered ciphertext
77
+ * fails to decrypt). Each envelope is self-describing JSON — `{ v, alg, keyId,
78
+ * salt, nonce, tag, ct }`, all base64url — carrying `keyId` so key ROTATION can
79
+ * tell old ciphertext from new. No secret is ever stored; only the passphrase
80
+ * supplied at run time can read it. `randomBytes`/`scryptSync` are injectable so
81
+ * round-trips are unit-testable deterministically.
82
+ */
83
+ export function createMirrorCipher(config = {}, deps = {}) {
84
+ const passphrase = config.passphrase || config.secret;
85
+ if (!passphrase) throw new Error("mirror encryption requires a `passphrase`");
86
+ const keyId = String(config.keyId || "default");
87
+ const _randomBytes = deps.randomBytes || randomBytes;
88
+ const _scrypt = deps.scryptSync || scryptSync;
89
+ const deriveKey = (salt) => _scrypt(String(passphrase), salt, 32);
90
+
91
+ return {
92
+ keyId,
93
+ encrypt(bytes) {
94
+ const salt = Buffer.from(_randomBytes(16));
95
+ const nonce = Buffer.from(_randomBytes(12));
96
+ const cipher = createCipheriv(CIPHER_ALGO, deriveKey(salt), nonce);
97
+ const ct = Buffer.concat([
98
+ cipher.update(Buffer.from(bytes, "utf-8")),
99
+ cipher.final(),
100
+ ]);
101
+ return JSON.stringify({
102
+ v: CIPHER_VERSION,
103
+ alg: CIPHER_ALGO,
104
+ keyId,
105
+ salt: salt.toString("base64url"),
106
+ nonce: nonce.toString("base64url"),
107
+ tag: cipher.getAuthTag().toString("base64url"),
108
+ ct: ct.toString("base64url"),
109
+ });
110
+ },
111
+ decrypt(envelope) {
112
+ let e;
113
+ try {
114
+ e = typeof envelope === "string" ? JSON.parse(envelope) : envelope;
115
+ } catch {
116
+ throw new Error("mirror ciphertext is not a valid envelope");
117
+ }
118
+ if (!e || e.v !== CIPHER_VERSION || e.alg !== CIPHER_ALGO) {
119
+ throw new Error("unsupported mirror ciphertext envelope");
120
+ }
121
+ const key = deriveKey(Buffer.from(e.salt, "base64url"));
122
+ const decipher = createDecipheriv(
123
+ CIPHER_ALGO,
124
+ key,
125
+ Buffer.from(e.nonce, "base64url"),
126
+ );
127
+ decipher.setAuthTag(Buffer.from(e.tag, "base64url"));
128
+ const pt = Buffer.concat([
129
+ decipher.update(Buffer.from(e.ct, "base64url")),
130
+ decipher.final(),
131
+ ]);
132
+ return pt.toString("utf-8");
133
+ },
134
+ /** Read an envelope's keyId without decrypting (rotation bookkeeping). */
135
+ envelopeKeyId(envelope) {
136
+ try {
137
+ const e =
138
+ typeof envelope === "string" ? JSON.parse(envelope) : envelope;
139
+ return e && e.keyId != null ? String(e.keyId) : null;
140
+ } catch {
141
+ return null;
142
+ }
143
+ },
144
+ };
145
+ }
146
+
147
+ /**
148
+ * Wrap a driver so push encrypts and pull decrypts transparently, keeping the
149
+ * raw driver reachable as `.raw` (key rotation drives the raw driver with two
150
+ * ciphers). list/delete pass through — ids stay plaintext in the target (they
151
+ * are random handles, not content).
152
+ */
153
+ function wrapEncryptedMirror(driver, cipher) {
154
+ return {
155
+ ...driver,
156
+ encrypted: true,
157
+ keyId: cipher.keyId,
158
+ raw: driver,
159
+ cipher,
160
+ async push(id, bytes) {
161
+ const enc = cipher.encrypt(bytes);
162
+ const r = await driver.push(id, enc);
163
+ return {
164
+ ...r,
165
+ bytes: Buffer.byteLength(bytes),
166
+ storedBytes: Buffer.byteLength(enc),
167
+ encrypted: true,
168
+ };
169
+ },
170
+ async pull(id) {
171
+ const raw = await driver.pull(id);
172
+ return raw == null ? null : cipher.decrypt(raw);
173
+ },
174
+ };
175
+ }
176
+
177
+ /**
178
+ * RETENTION: delete every mirror entry whose id is NOT in `keep` — the mirror is
179
+ * one-way derived from the local store, so retention = "hold only what the local
180
+ * source still keeps" (e.g. the ids `cc session list` still returns). Best-effort
181
+ * per id; returns what was deleted vs kept.
182
+ */
183
+ export async function pruneMirror(mirror, { keep = [] } = {}) {
184
+ if (!mirror || typeof mirror.list !== "function") {
185
+ return { deleted: [], kept: [] };
186
+ }
187
+ const keepSet = new Set((keep || []).map(String));
188
+ const ids = (await mirror.list()) || [];
189
+ const deleted = [];
190
+ for (const id of ids) {
191
+ if (keepSet.has(String(id))) continue;
192
+ try {
193
+ await mirror.delete(id);
194
+ deleted.push(id);
195
+ } catch {
196
+ /* best-effort — a failed delete must not abort the prune */
197
+ }
198
+ }
199
+ return { deleted, kept: ids.filter((i) => keepSet.has(String(i))) };
200
+ }
201
+
202
+ /**
203
+ * KEY ROTATION: re-encrypt every mirrored session from the `from` cipher to the
204
+ * `to` cipher, in place. Drives the RAW driver (pass `mirror.raw` for an
205
+ * encrypted mirror) so it reads/writes ciphertext directly. Entries already at
206
+ * `to.keyId` are skipped (idempotent re-runs); an entry that fails to decrypt
207
+ * with `from` is reported, not silently dropped.
208
+ */
209
+ export async function rotateMirrorKey(driver, { from, to } = {}) {
210
+ if (!driver || !from || !to) {
211
+ throw new Error("rotateMirrorKey requires (driver, { from, to })");
212
+ }
213
+ const ids = (await driver.list()) || [];
214
+ const rotated = [];
215
+ const skipped = [];
216
+ const failed = [];
217
+ for (const id of ids) {
218
+ let raw;
219
+ try {
220
+ raw = await driver.pull(id);
221
+ } catch (e) {
222
+ failed.push({ id, error: e.message });
223
+ continue;
224
+ }
225
+ if (raw == null) continue;
226
+ if (from.envelopeKeyId(raw) === to.keyId) {
227
+ skipped.push(id);
228
+ continue;
229
+ }
230
+ try {
231
+ const plain = from.decrypt(raw);
232
+ await driver.push(id, to.encrypt(plain));
233
+ rotated.push(id);
234
+ } catch (e) {
235
+ failed.push({ id, error: e.message });
236
+ }
237
+ }
238
+ return { rotated, skipped, failed, keyId: to.keyId };
51
239
  }
52
240
 
53
241
  /** Directory mirror — the reference driver. */
@@ -59,6 +247,7 @@ export function createFsMirror(config = {}, deps = {}) {
59
247
  const _readFileSync = deps.readFileSync || readFileSync;
60
248
  const _writeFileSync = deps.writeFileSync || writeFileSync;
61
249
  const _readdirSync = deps.readdirSync || readdirSync;
250
+ const _unlinkSync = deps.unlinkSync || unlinkSync;
62
251
 
63
252
  const ensure = () => {
64
253
  if (!_existsSync(dir)) _mkdirSync(dir, { recursive: true });
@@ -88,6 +277,12 @@ export function createFsMirror(config = {}, deps = {}) {
88
277
  .filter((f) => f.endsWith(".jsonl"))
89
278
  .map((f) => basename(f, ".jsonl"));
90
279
  },
280
+ async delete(id) {
281
+ const f = fileFor(id);
282
+ if (!_existsSync(f)) return { id, deleted: false };
283
+ _unlinkSync(f);
284
+ return { id, deleted: true };
285
+ },
91
286
  };
92
287
  }
93
288
 
@@ -138,5 +333,16 @@ export function createHttpMirror(config = {}, deps = {}) {
138
333
  .map((x) => (typeof x === "string" ? x : x?.id))
139
334
  .filter(Boolean);
140
335
  },
336
+ async delete(id) {
337
+ const res = await doFetch(`${baseUrl}/sessions/${guard(id)}`, {
338
+ method: "DELETE",
339
+ headers: headers(),
340
+ });
341
+ // 404 = already gone → idempotent success, not an error.
342
+ if (!res.ok && res.status !== 404) {
343
+ throw new Error(`mirror DELETE ${id} → HTTP ${res.status}`);
344
+ }
345
+ return { id, deleted: res.ok };
346
+ },
141
347
  };
142
348
  }
@@ -0,0 +1,288 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * async-hook-queue — a small, best-effort, PERSISTENT queue for async settings
5
+ * hooks that opted into `asyncRewake: true` and finished in a FAILURE state.
6
+ *
7
+ * Why: AsyncHookSupervisor runs `async: true` hooks fire-and-forget and keeps
8
+ * their results ONLY in memory (`_results`/`_rewakes`). A rewake is the one
9
+ * actionable signal it produces — "a background check (e.g. `npm test`) failed,
10
+ * re-engage the agent with the error". If the process dies (crash, kill,
11
+ * hard exit) after the rewake was recorded but BEFORE the turn loop drained and
12
+ * surfaced it, that signal is silently lost: the next `--resume` of the same
13
+ * session has no idea a background check failed.
14
+ *
15
+ * This module gives the supervisor a durable place to park undrained rewakes,
16
+ * keyed by session id, so `--resume <id>` can recover them. The gap's P2 "Hooks"
17
+ * item calls for an "async hook 持久队列"; this is the reliability half of it
18
+ * (paired with the already-shipped `allowManagedHooksOnly`).
19
+ *
20
+ * Lifecycle (rewake-scoped on purpose — non-rewake results are informational and
21
+ * cheap to lose; rewakes are the failure signal worth surviving a crash):
22
+ * record a rewake → appendRewake() (immediate write, survives even SIGKILL)
23
+ * drain / surface → removeRewakes() (consumed → no longer pending)
24
+ * resume a session → takePending() (load + clear that session's bucket)
25
+ *
26
+ * Design mirrors hook-stats-store.cjs: pure folds (no clock/RNG/fs inside), an
27
+ * injected fs for load/save, atomic tmp+rename writes, and per-session +
28
+ * per-session-count bounds so a runaway producer never grows the file without
29
+ * bound. Every path is best-effort — a corrupt/io-failed queue file must never
30
+ * block a hook or the agent.
31
+ */
32
+
33
+ const os = require("node:os");
34
+ const path = require("node:path");
35
+ const fsDefault = require("node:fs");
36
+
37
+ const CONFIG_DIR_NAME = ".chainlesschain";
38
+ const MAX_RECORDS_PER_SESSION = 200; // drop oldest-recorded beyond this
39
+ const MAX_SESSIONS = 100; // bound distinct session buckets
40
+ const DEFAULT_STALE_MS = 7 * 24 * 60 * 60 * 1000; // prune buckets untouched >7d
41
+
42
+ /** Default on-disk location (matches the ESM getHomeDir() layout). */
43
+ function defaultAsyncHookQueuePath() {
44
+ return path.join(os.homedir(), CONFIG_DIR_NAME, "async-hook-queue.json");
45
+ }
46
+
47
+ /**
48
+ * Stable de-dupe key for one rewake record. Two records with the same
49
+ * (command, event, ts, ms, exitCode) are treated as the same pending item —
50
+ * enough to make removeRewakes() idempotent and appendRewake() not double-store
51
+ * a record that a caller replays. Pure, no hashing needed.
52
+ */
53
+ function queueEntryKey(rec) {
54
+ if (!rec || typeof rec !== "object") return "";
55
+ return [
56
+ String(rec.command || ""),
57
+ String(rec.event || ""),
58
+ String(rec.ts ?? ""),
59
+ String(rec.ms ?? ""),
60
+ String(rec.exitCode ?? ""),
61
+ ].join("|");
62
+ }
63
+
64
+ /** Normalize a raw supervisor record into a compact stored rewake entry. */
65
+ function normalizeRecord(rec) {
66
+ if (!rec || typeof rec !== "object") return null;
67
+ const command = String(rec.command || "").trim();
68
+ if (!command) return null;
69
+ return {
70
+ command,
71
+ event: rec.event ?? null,
72
+ exitCode: rec.exitCode ?? null,
73
+ error: rec.error ? String(rec.error) : null,
74
+ additionalContext: rec.additionalContext
75
+ ? String(rec.additionalContext)
76
+ : null,
77
+ blocked: !!rec.blocked,
78
+ ts: Number.isFinite(Number(rec.ts)) ? Number(rec.ts) : 0,
79
+ ms: Number.isFinite(Number(rec.ms)) ? Number(rec.ms) : 0,
80
+ };
81
+ }
82
+
83
+ /** Fresh empty queue. */
84
+ function emptyQueue() {
85
+ return { sessions: {} };
86
+ }
87
+
88
+ /** Coerce an arbitrary parsed value into the queue shape. */
89
+ function coerceQueue(parsed) {
90
+ if (
91
+ !parsed ||
92
+ typeof parsed !== "object" ||
93
+ typeof parsed.sessions !== "object" ||
94
+ !parsed.sessions
95
+ ) {
96
+ return emptyQueue();
97
+ }
98
+ return { sessions: parsed.sessions };
99
+ }
100
+
101
+ /**
102
+ * PURE fold: append `records` into `queue` under `sessionId` (mutates + returns
103
+ * `queue`). De-dupes by queueEntryKey, keeps the most-recent MAX_RECORDS_PER_
104
+ * SESSION, and stamps the bucket's `updatedAt` for stale pruning. `now` is
105
+ * supplied by the caller so the fold stays clock-free.
106
+ */
107
+ function foldAppend(queue, sessionId, records, now) {
108
+ if (!queue || typeof queue !== "object") queue = emptyQueue();
109
+ if (!queue.sessions) queue.sessions = {};
110
+ const id = String(sessionId || "").trim();
111
+ if (!id) return queue;
112
+ const list = Array.isArray(records) ? records : [records];
113
+ const bucket = queue.sessions[id] || { records: [], updatedAt: 0 };
114
+ const seen = new Set(bucket.records.map(queueEntryKey));
115
+ for (const raw of list) {
116
+ const rec = normalizeRecord(raw);
117
+ if (!rec) continue;
118
+ const key = queueEntryKey(rec);
119
+ if (seen.has(key)) continue;
120
+ seen.add(key);
121
+ bucket.records.push(rec);
122
+ }
123
+ // Never persist an empty bucket (all-invalid input, or nothing new for a
124
+ // session that had none) — it would just be dead weight in the file.
125
+ if (bucket.records.length === 0) return queue;
126
+ if (bucket.records.length > MAX_RECORDS_PER_SESSION) {
127
+ bucket.records = bucket.records.slice(
128
+ bucket.records.length - MAX_RECORDS_PER_SESSION,
129
+ );
130
+ }
131
+ bucket.updatedAt = Number.isFinite(Number(now))
132
+ ? Number(now)
133
+ : bucket.updatedAt;
134
+ queue.sessions[id] = bucket;
135
+ boundSessions(queue);
136
+ return queue;
137
+ }
138
+
139
+ /**
140
+ * PURE fold: remove `records` (by key) from `sessionId`'s bucket (mutates +
141
+ * returns `queue`). Empties → the bucket is deleted so the file self-cleans.
142
+ */
143
+ function foldRemove(queue, sessionId, records) {
144
+ if (!queue || !queue.sessions) return queue || emptyQueue();
145
+ const id = String(sessionId || "").trim();
146
+ const bucket = queue.sessions[id];
147
+ if (!bucket) return queue;
148
+ const kill = new Set(
149
+ (Array.isArray(records) ? records : [records])
150
+ .map(normalizeRecord)
151
+ .filter(Boolean)
152
+ .map(queueEntryKey),
153
+ );
154
+ bucket.records = bucket.records.filter((r) => !kill.has(queueEntryKey(r)));
155
+ if (bucket.records.length === 0) delete queue.sessions[id];
156
+ return queue;
157
+ }
158
+
159
+ /** Keep only the MAX_SESSIONS most-recently-updated buckets. */
160
+ function boundSessions(queue) {
161
+ if (!queue || !queue.sessions) return queue;
162
+ const ids = Object.keys(queue.sessions);
163
+ if (ids.length <= MAX_SESSIONS) return queue;
164
+ ids
165
+ .sort(
166
+ (a, b) =>
167
+ (queue.sessions[a].updatedAt || 0) - (queue.sessions[b].updatedAt || 0),
168
+ )
169
+ .slice(0, ids.length - MAX_SESSIONS)
170
+ .forEach((id) => delete queue.sessions[id]);
171
+ return queue;
172
+ }
173
+
174
+ /**
175
+ * PURE fold: drop buckets whose `updatedAt` is older than `now - staleMs`
176
+ * (mutates + returns `queue`). Guards the file against unbounded growth from
177
+ * sessions that were never resumed to drain their pending rewakes.
178
+ */
179
+ function foldPruneStale(queue, now, staleMs = DEFAULT_STALE_MS) {
180
+ if (!queue || !queue.sessions) return queue || emptyQueue();
181
+ const cutoff = (Number.isFinite(Number(now)) ? Number(now) : 0) - staleMs;
182
+ for (const [id, bucket] of Object.entries(queue.sessions)) {
183
+ if (!bucket || (bucket.updatedAt || 0) < cutoff) delete queue.sessions[id];
184
+ }
185
+ return queue;
186
+ }
187
+
188
+ /** Best-effort read of the on-disk queue (→ empty on missing/corrupt). */
189
+ function loadQueue(file = defaultAsyncHookQueuePath(), fs = fsDefault) {
190
+ try {
191
+ if (!fs.existsSync(file)) return emptyQueue();
192
+ return coerceQueue(JSON.parse(fs.readFileSync(file, "utf-8")));
193
+ } catch {
194
+ return emptyQueue();
195
+ }
196
+ }
197
+
198
+ /** Best-effort atomic write (tmp + rename). Never throws. */
199
+ function saveQueue(queue, file = defaultAsyncHookQueuePath(), fs = fsDefault) {
200
+ try {
201
+ const dir = path.dirname(file);
202
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
203
+ const tmp = `${file}.tmp`;
204
+ fs.writeFileSync(tmp, JSON.stringify(queue), "utf-8");
205
+ fs.renameSync(tmp, file);
206
+ return true;
207
+ } catch {
208
+ return false;
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Append one/more rewake records for `sessionId` and write back. Best-effort;
214
+ * returns true on a successful write. `now` defaults to Date.now() (the only
215
+ * non-pure entry point besides load/save).
216
+ */
217
+ function appendRewake(
218
+ { sessionId, records, now } = {},
219
+ file = defaultAsyncHookQueuePath(),
220
+ fs = fsDefault,
221
+ ) {
222
+ const id = String(sessionId || "").trim();
223
+ if (!id || records == null) return false;
224
+ const at = Number.isFinite(Number(now)) ? Number(now) : Date.now();
225
+ const queue = foldAppend(loadQueue(file, fs), id, records, at);
226
+ return saveQueue(queue, file, fs);
227
+ }
228
+
229
+ /**
230
+ * Remove (mark consumed) rewake records for `sessionId` and write back.
231
+ * Best-effort; a no-op when nothing matches.
232
+ */
233
+ function removeRewakes(
234
+ { sessionId, records } = {},
235
+ file = defaultAsyncHookQueuePath(),
236
+ fs = fsDefault,
237
+ ) {
238
+ const id = String(sessionId || "").trim();
239
+ if (!id || records == null) return false;
240
+ const queue = foldRemove(loadQueue(file, fs), id, records);
241
+ return saveQueue(queue, file, fs);
242
+ }
243
+
244
+ /**
245
+ * Load a session's pending rewakes AND clear its bucket (also prunes stale
246
+ * buckets while we hold the file). Returns the recovered records (possibly []).
247
+ * The single call a resuming runner makes on startup.
248
+ */
249
+ function takePending(
250
+ { sessionId, now, staleMs } = {},
251
+ file = defaultAsyncHookQueuePath(),
252
+ fs = fsDefault,
253
+ ) {
254
+ const id = String(sessionId || "").trim();
255
+ if (!id) return [];
256
+ const at = Number.isFinite(Number(now)) ? Number(now) : Date.now();
257
+ let queue = loadQueue(file, fs);
258
+ const bucket = queue.sessions[id];
259
+ const recovered =
260
+ bucket && Array.isArray(bucket.records) ? bucket.records : [];
261
+ if (bucket) delete queue.sessions[id];
262
+ queue = foldPruneStale(queue, at, staleMs);
263
+ // Only rewrite if we actually changed something (recovered a bucket or the
264
+ // prune dropped one) — a pure read of an empty/unrelated queue leaves the
265
+ // file byte-unchanged.
266
+ if (recovered.length > 0 || bucket) saveQueue(queue, file, fs);
267
+ return recovered;
268
+ }
269
+
270
+ module.exports = {
271
+ defaultAsyncHookQueuePath,
272
+ queueEntryKey,
273
+ normalizeRecord,
274
+ emptyQueue,
275
+ coerceQueue,
276
+ foldAppend,
277
+ foldRemove,
278
+ foldPruneStale,
279
+ boundSessions,
280
+ loadQueue,
281
+ saveQueue,
282
+ appendRewake,
283
+ removeRewakes,
284
+ takePending,
285
+ MAX_RECORDS_PER_SESSION,
286
+ MAX_SESSIONS,
287
+ DEFAULT_STALE_MS,
288
+ };
@@ -73,6 +73,22 @@ class AsyncHookSupervisor {
73
73
  ? this._statsStore.defaultHookStatsPath()
74
74
  : null);
75
75
  this._statsFs = opts.statsFs || null; // injected fs for tests
76
+ // Persistent rewake queue (opt-in). A rewake — an async check that OPTED IN
77
+ // and finished in a FAILURE state — is the one actionable signal this
78
+ // supervisor produces, but it lives only in `_rewakes` until the turn loop
79
+ // drains it. If the process dies in that window the failure is lost. When a
80
+ // `sessionId` + `persistQueue` (or an explicit `queuePath`) are supplied,
81
+ // each rewake is durably parked keyed by session so `--resume` can recover
82
+ // it; it's removed again the moment the run drains it. Disabled (no writes,
83
+ // byte-unchanged) when either is absent — mirrors the stats-store opt-in.
84
+ this._queueStore = opts.queueStore || require("./async-hook-queue.cjs");
85
+ this._queueSessionId = opts.sessionId ? String(opts.sessionId) : null;
86
+ this._queuePath =
87
+ opts.queuePath ||
88
+ (opts.persistQueue === true
89
+ ? this._queueStore.defaultAsyncHookQueuePath()
90
+ : null);
91
+ this._queueFs = opts.queueFs || null; // injected fs for tests
76
92
  // The exit reaper is installed LAZILY (on first spawn), not in the ctor:
77
93
  // a supervisor that never dispatches a hook (or the many built in tests)
78
94
  // must not leave a permanent `process.once('exit')` listener behind —
@@ -359,6 +375,29 @@ class AsyncHookSupervisor {
359
375
  if (hook.asyncRewake && !rec.ok) {
360
376
  this._rewakes.push(rec);
361
377
  if (this._rewakes.length > RESULT_RING) this._rewakes.shift();
378
+ // Durably park it (opt-in) so a crash before the turn loop drains this
379
+ // rewake doesn't lose the failure signal. Immediate write, not deferred to
380
+ // stopAll — a rewake must survive even a hard exit that never runs stopAll.
381
+ this._persistRewake(rec);
382
+ }
383
+ }
384
+
385
+ /** True when a session id + queue location were both configured. */
386
+ _queueEnabled() {
387
+ return !!(this._queueSessionId && this._queuePath);
388
+ }
389
+
390
+ /** Best-effort durable append of one rewake record. Never throws. */
391
+ _persistRewake(rec) {
392
+ if (!this._queueEnabled()) return;
393
+ try {
394
+ this._queueStore.appendRewake(
395
+ { sessionId: this._queueSessionId, records: [rec], now: rec.ts },
396
+ this._queuePath,
397
+ this._queueFs || undefined,
398
+ );
399
+ } catch {
400
+ /* queue is best-effort */
362
401
  }
363
402
  }
364
403
 
@@ -377,6 +416,19 @@ class AsyncHookSupervisor {
377
416
  drainRewakes() {
378
417
  const r = this._rewakes;
379
418
  this._rewakes = [];
419
+ // The run has now consumed these rewakes (it will surface / re-engage on
420
+ // them), so they no longer need to survive a crash — drop the durable copies.
421
+ if (r.length > 0 && this._queueEnabled()) {
422
+ try {
423
+ this._queueStore.removeRewakes(
424
+ { sessionId: this._queueSessionId, records: r },
425
+ this._queuePath,
426
+ this._queueFs || undefined,
427
+ );
428
+ } catch {
429
+ /* queue is best-effort */
430
+ }
431
+ }
380
432
  return r;
381
433
  }
382
434