chainlesschain 0.162.125 → 0.162.127

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 (192) hide show
  1. package/package.json +2 -2
  2. package/src/assets/web-panel/assets/{AIOps-DcqNhZhA.js → AIOps-CsHJh6tT.js} +1 -1
  3. package/src/assets/web-panel/assets/{ActionButton-BigtmPoq.js → ActionButton-ClDr78Wl.js} +1 -1
  4. package/src/assets/web-panel/assets/{Analytics-1J_X_HF4.js → Analytics-CQQBX5mv.js} +3 -3
  5. package/src/assets/web-panel/assets/{AppLayout-DlcDG_a_.js → AppLayout-DIofBNeG.js} +5 -5
  6. package/src/assets/web-panel/assets/{Audit-Do-y8_3w.js → Audit-d8pxGJLK.js} +1 -1
  7. package/src/assets/web-panel/assets/{Backup-S2Df-50Y.js → Backup-ChZb31ek.js} +1 -1
  8. package/src/assets/web-panel/assets/{BaseInput-D5kgWJfk.js → BaseInput-CQsSXb5v.js} +1 -1
  9. package/src/assets/web-panel/assets/{Chat-aUcp3kN1.js → Chat-C_mjILx8.js} +6 -6
  10. package/src/assets/web-panel/assets/ChatBubbleRenderer-B545kWla.js +1 -0
  11. package/src/assets/web-panel/assets/{Checkbox-DTg1U7Xs.js → Checkbox-CWFmVuo8.js} +1 -1
  12. package/src/assets/web-panel/assets/{Codegen-YO9ZZ4Ky.js → Codegen-DwicePOQ.js} +1 -1
  13. package/src/assets/web-panel/assets/{Col-BHonE9fU.js → Col-B36SnRdL.js} +1 -1
  14. package/src/assets/web-panel/assets/{Community-DA2AlEFh.js → Community-C_sZ2HhK.js} +1 -1
  15. package/src/assets/web-panel/assets/{Compact-DULxLRNg.js → Compact-B2jzDcUl.js} +1 -1
  16. package/src/assets/web-panel/assets/{Compliance-BZqfuuZL.js → Compliance-BD58_IHe.js} +1 -1
  17. package/src/assets/web-panel/assets/{Cowork-CJe3vyMS.js → Cowork-VIBdGc4D.js} +2 -2
  18. package/src/assets/web-panel/assets/{Cron-CeV8AtRu.js → Cron-CDTVWUmV.js} +2 -2
  19. package/src/assets/web-panel/assets/{Crosschain-CAg66zS5.js → Crosschain-D5DkGNKU.js} +1 -1
  20. package/src/assets/web-panel/assets/{DID-Dtup5JZm.js → DID-B23ae8PQ.js} +2 -2
  21. package/src/assets/web-panel/assets/{Dashboard-DAR_8PNy.js → Dashboard-DTG5MsSx.js} +2 -2
  22. package/src/assets/web-panel/assets/{Dropdown-K5bTaCYN.js → Dropdown-D35RRnMZ.js} +1 -1
  23. package/src/assets/web-panel/assets/{EmailListRenderer-C890uErx.js → EmailListRenderer-DRvY2nPk.js} +1 -1
  24. package/src/assets/web-panel/assets/{FamilyGuardDashboard-B-Tj_8yM.js → FamilyGuardDashboard-BT_eESkk.js} +1 -1
  25. package/src/assets/web-panel/assets/{Federation-C6WZ98ni.js → Federation-CxX9F7IL.js} +1 -1
  26. package/src/assets/web-panel/assets/{FormItemContext-D-LTfGWd.js → FormItemContext-C1xDz6D8.js} +1 -1
  27. package/src/assets/web-panel/assets/{GenericCardRenderer-C80DK4W7.js → GenericCardRenderer-CjBOAXcO.js} +1 -1
  28. package/src/assets/web-panel/assets/{Git-Cjvvv3zW.js → Git-I9I9NkPO.js} +2 -2
  29. package/src/assets/web-panel/assets/{Governance-C0BND77H.js → Governance-BiigWxQW.js} +1 -1
  30. package/src/assets/web-panel/assets/{Inference-BL9kTtSu.js → Inference-C0KrSCgh.js} +1 -1
  31. package/src/assets/web-panel/assets/{KnowledgeGraph-CnVTBmJf.js → KnowledgeGraph-B1Nd54ow.js} +1 -1
  32. package/src/assets/web-panel/assets/{Logs-CbCbg7K6.js → Logs-CeOtvYIo.js} +2 -2
  33. package/src/assets/web-panel/assets/{Marketplace-CG5On-fH.js → Marketplace-CRhgvAVQ.js} +1 -1
  34. package/src/assets/web-panel/assets/{McpTools-AYYDZZK7.js → McpTools-BIJNah-H.js} +5 -5
  35. package/src/assets/web-panel/assets/{Memory-tMWbMDQq.js → Memory-D6gsbo4e.js} +2 -2
  36. package/src/assets/web-panel/assets/{MobileBridge-BAOsf4wB.js → MobileBridge-DDkIKA6U.js} +1 -1
  37. package/src/assets/web-panel/assets/{MobileProjects-BT-2komQ.js → MobileProjects-BBkI02Lf.js} +1 -1
  38. package/src/assets/web-panel/assets/{Mtc-BFwfC63n.js → Mtc-BziHK7HS.js} +5 -5
  39. package/src/assets/web-panel/assets/{MtcAudit-DYG3CWdH.js → MtcAudit-CWXjbkeF.js} +2 -2
  40. package/src/assets/web-panel/assets/{Multisig-KJwd0yWT.js → Multisig-9jNF_VWa.js} +3 -3
  41. package/src/assets/web-panel/assets/{NLProgramming-BmL5dNWm.js → NLProgramming-BUdF7I2G.js} +1 -1
  42. package/src/assets/web-panel/assets/{Notes-DmkaVaMc.js → Notes-D5ls1r1T.js} +3 -3
  43. package/src/assets/web-panel/assets/{NotificationSettings-tmVQszDZ.js → NotificationSettings-B2WX_TMX.js} +1 -1
  44. package/src/assets/web-panel/assets/{OrderTableRenderer-BtrR7anf.js → OrderTableRenderer-COSzdTcE.js} +1 -1
  45. package/src/assets/web-panel/assets/{Organization-pppktQyW.js → Organization-tzWIgnbM.js} +4 -4
  46. package/src/assets/web-panel/assets/{Overflow-D3lBoQDA.js → Overflow-Ca1gp_3S.js} +1 -1
  47. package/src/assets/web-panel/assets/{P2P-BgE2qGlZ.js → P2P-XnfDfRK9.js} +2 -2
  48. package/src/assets/web-panel/assets/{PdhVaultBrowser-D26Bz5gS.js → PdhVaultBrowser-23Susb25.js} +3 -3
  49. package/src/assets/web-panel/assets/{Permissions-BR8no2Xe.js → Permissions-CnkhYPqt.js} +4 -4
  50. package/src/assets/web-panel/assets/{PersonalDataHub-DabbyQEF.js → PersonalDataHub-CZ1UasJc.js} +2 -2
  51. package/src/assets/web-panel/assets/{Pipeline-n4sNpEz8.js → Pipeline-DC1oC84K.js} +1 -1
  52. package/src/assets/web-panel/assets/{Privacy-CGNp4n8M.js → Privacy-CdqayaCE.js} +1 -1
  53. package/src/assets/web-panel/assets/{ProjectInit-CAVA5VsX.js → ProjectInit-DS7NbGQL.js} +2 -2
  54. package/src/assets/web-panel/assets/{ProjectSettings-GCgkfM7A.js → ProjectSettings-fdzwup3n.js} +2 -2
  55. package/src/assets/web-panel/assets/{Projects-BYK17p52.js → Projects-FvodeZob.js} +1 -1
  56. package/src/assets/web-panel/assets/{Providers-DpUT5W-d.js → Providers-IxYyzeSI.js} +1 -1
  57. package/src/assets/web-panel/assets/{QuickAsk-ZJxTb7vi.js → QuickAsk-BAtFET-B.js} +1 -1
  58. package/src/assets/web-panel/assets/{Recommend-CIzFRDk1.js → Recommend-DCNRV9Vr.js} +1 -1
  59. package/src/assets/web-panel/assets/{Reputation-lQ_WDNZn.js → Reputation-lg2JmIai.js} +1 -1
  60. package/src/assets/web-panel/assets/{Row-1MEtrgtF.js → Row-BAhyDuri.js} +1 -1
  61. package/src/assets/web-panel/assets/{RssFeed-SSgcPPl4.js → RssFeed-BA_3issD.js} +2 -2
  62. package/src/assets/web-panel/assets/{Search-CcTMOGNY.js → Search-CJheUJDl.js} +1 -1
  63. package/src/assets/web-panel/assets/{Security-BA1KMaDx.js → Security-D2VczQDD.js} +3 -3
  64. package/src/assets/web-panel/assets/{Services-_aj7czZn.js → Services-7ZFAzqVR.js} +2 -2
  65. package/src/assets/web-panel/assets/{Skeleton-BWgg_0Zr.js → Skeleton-CsBoTasD.js} +1 -1
  66. package/src/assets/web-panel/assets/{Skills-C2ZIziYM.js → Skills-DT_j_gj0.js} +1 -1
  67. package/src/assets/web-panel/assets/{Sla-CYxp4axY.js → Sla-C9D5geJx.js} +1 -1
  68. package/src/assets/web-panel/assets/{SpeechSettings-C7Csp1jV.js → SpeechSettings-CWCNVW42.js} +1 -1
  69. package/src/assets/web-panel/assets/{SyncSettings-svGeswiw.js → SyncSettings-TYCbf2kk.js} +2 -2
  70. package/src/assets/web-panel/assets/{Tasks-ClISj6oK.js → Tasks-CbRoXu1G.js} +1 -1
  71. package/src/assets/web-panel/assets/{Templates-CmO-Fp7r.js → Templates-CxBjIrkJ.js} +1 -1
  72. package/src/assets/web-panel/assets/{Tenant-a3Ac3lxz.js → Tenant-NKVyFsPM.js} +1 -1
  73. package/src/assets/web-panel/assets/{Terminal-DyJIRh81.js → Terminal-D5aAL5_H.js} +2 -2
  74. package/src/assets/web-panel/assets/TimelineRenderer-CD1JE0Rb.js +1 -0
  75. package/src/assets/web-panel/assets/{Tokens-DJW0KqV4.js → Tokens-CdepBSYe.js} +1 -1
  76. package/src/assets/web-panel/assets/{Trigger-0HeTi4r6.js → Trigger-Br3n_PEh.js} +1 -1
  77. package/src/assets/web-panel/assets/{Trust-aFym34eo.js → Trust-CSac0pMf.js} +1 -1
  78. package/src/assets/web-panel/assets/{UkeySign-CYhszHJv.js → UkeySign-BiWo43zc.js} +1 -1
  79. package/src/assets/web-panel/assets/{VideoEditing-BbJxPF9X.js → VideoEditing-Dj50GuoF.js} +1 -1
  80. package/src/assets/web-panel/assets/{Wallet-DDthEwiu.js → Wallet-CQK_g4_U.js} +4 -4
  81. package/src/assets/web-panel/assets/{WebAuthn-DQ6McYPg.js → WebAuthn-D8mBl1NC.js} +3 -3
  82. package/src/assets/web-panel/assets/{WorkflowEditor-CnF8zRsi.js → WorkflowEditor-CHeS_vab.js} +1 -1
  83. package/src/assets/web-panel/assets/{chat-Dzkx2gTP.js → chat-CbeOf_lH.js} +1 -1
  84. package/src/assets/web-panel/assets/{colors-35T51Oo5.js → colors-CTFiyfed.js} +1 -1
  85. package/src/assets/web-panel/assets/{compact-item-TtzNrlu1.js → compact-item-CZsh8FXE.js} +1 -1
  86. package/src/assets/web-panel/assets/{createContext-Y1LkWrYb.js → createContext-UPhnXsap.js} +1 -1
  87. package/src/assets/web-panel/assets/devWarning-BFRqfgsk.js +1 -0
  88. package/src/assets/web-panel/assets/{hasIn-BOYw1BgS.js → hasIn-CCqQtSY-.js} +1 -1
  89. package/src/assets/web-panel/assets/{index-D2od7Bgx.js → index-1d4pat4L.js} +1 -1
  90. package/src/assets/web-panel/assets/{index-NM9Oke2k.js → index-86dmMEZY.js} +1 -1
  91. package/src/assets/web-panel/assets/{index-Dof7lWA_.js → index-B-OcSTNA.js} +1 -1
  92. package/src/assets/web-panel/assets/{index-D7dCBw_M.js → index-B1tylGKq.js} +1 -1
  93. package/src/assets/web-panel/assets/{index-CqhwG1ox.js → index-BAcem_Qs.js} +1 -1
  94. package/src/assets/web-panel/assets/{index-CZ16GlOs.js → index-BKADY9Iw.js} +1 -1
  95. package/src/assets/web-panel/assets/{index-CPGNc2tF.js → index-Be0uH7m1.js} +1 -1
  96. package/src/assets/web-panel/assets/{index-DAizH273.js → index-Bga0UuWA.js} +1 -1
  97. package/src/assets/web-panel/assets/{index-DFhlelzN.js → index-Blgzobgm.js} +1 -1
  98. package/src/assets/web-panel/assets/{index-D6tG4B25.js → index-BwPULPuj.js} +1 -1
  99. package/src/assets/web-panel/assets/{index-DjeUZxE5.js → index-CIQVlE9u.js} +1 -1
  100. package/src/assets/web-panel/assets/{index-CLqC2sRT.js → index-CMi91z74.js} +1 -1
  101. package/src/assets/web-panel/assets/{index-hyqEKkBT.js → index-CNteMctn.js} +1 -1
  102. package/src/assets/web-panel/assets/{index-Cs82BHAj.js → index-CQHQiYAO.js} +1 -1
  103. package/src/assets/web-panel/assets/{index-zF77jnI2.js → index-CVKFSFkX.js} +1 -1
  104. package/src/assets/web-panel/assets/{index-G4ClSmJc.js → index-CctoAd9I.js} +1 -1
  105. package/src/assets/web-panel/assets/{index-DhMSOd_2.js → index-CdiuRSqw.js} +1 -1
  106. package/src/assets/web-panel/assets/{index-BWgNn9As.js → index-Cy83zglX.js} +3 -3
  107. package/src/assets/web-panel/assets/{index-Bw1iOGhM.js → index-DBJjqjxL.js} +1 -1
  108. package/src/assets/web-panel/assets/{index-D4BgCBa3.js → index-DFbyobqi.js} +1 -1
  109. package/src/assets/web-panel/assets/{index-BKRlWHUp.js → index-DcJxrM1F.js} +1 -1
  110. package/src/assets/web-panel/assets/{index-B7aKAZzS.js → index-DgXn2sWa.js} +1 -1
  111. package/src/assets/web-panel/assets/{index-D47robkX.js → index-Dkn1r09O.js} +1 -1
  112. package/src/assets/web-panel/assets/{index-CVrUs6rf.js → index-DmScDPXc.js} +1 -1
  113. package/src/assets/web-panel/assets/{index-Bs69SI96.js → index-DpTaXoo4.js} +1 -1
  114. package/src/assets/web-panel/assets/index-DqADH38Q.js +1 -0
  115. package/src/assets/web-panel/assets/{index-BxWUEFKy.js → index-DvaDSd9k.js} +1 -1
  116. package/src/assets/web-panel/assets/{index-BtlJWaSP.js → index-LIFKNtNq.js} +1 -1
  117. package/src/assets/web-panel/assets/{index-BGPEaeB1.js → index-LyzbfmcN.js} +1 -1
  118. package/src/assets/web-panel/assets/index-Pbx8iJt_.js +1 -0
  119. package/src/assets/web-panel/assets/{index-KgXrlfNF.js → index-WGmri9tu.js} +1 -1
  120. package/src/assets/web-panel/assets/{index-XIuZV9by.js → index-Yd1x-xhs.js} +1 -1
  121. package/src/assets/web-panel/assets/{index-Dhfw-BXs.js → index-_OOipSkX.js} +1 -1
  122. package/src/assets/web-panel/assets/{index-Dx0hIfC-.js → index-dwBroN51.js} +1 -1
  123. package/src/assets/web-panel/assets/{index-C7Wt5feN.js → index-e6tj_vqt.js} +1 -1
  124. package/src/assets/web-panel/assets/{index-CdyFg4FI.js → index-lOxvi-gf.js} +1 -1
  125. package/src/assets/web-panel/assets/{index-8jffdb6b.js → index-qTrskpW-.js} +1 -1
  126. package/src/assets/web-panel/assets/{index-DmkG479D.js → index-uH-glcG6.js} +1 -1
  127. package/src/assets/web-panel/assets/{index-BSpFe6j5.js → index-z5pyzCXj.js} +1 -1
  128. package/src/assets/web-panel/assets/{initDefaultProps-DD5y5msp.js → initDefaultProps-K95PVa57.js} +1 -1
  129. package/src/assets/web-panel/assets/{motion-BuoutMia.js → motion-DwTSyvvW.js} +1 -1
  130. package/src/assets/web-panel/assets/{move-DkpjqOXg.js → move-BuRSogkN.js} +1 -1
  131. package/src/assets/web-panel/assets/{mtc-parser-BmGJD-v-.js → mtc-parser-twEnPl1V.js} +1 -1
  132. package/src/assets/web-panel/assets/{omit-DftUE0Sz.js → omit-CdPhebkX.js} +1 -1
  133. package/src/assets/web-panel/assets/{pickAttrs-DDRb9FIu.js → pickAttrs-CGcIpdPi.js} +1 -1
  134. package/src/assets/web-panel/assets/{placementArrow-DPgtxOOD.js → placementArrow-DixkX9VV.js} +1 -1
  135. package/src/assets/web-panel/assets/{responsiveObserve-BQFRzFeZ.js → responsiveObserve-C74t3rB5.js} +1 -1
  136. package/src/assets/web-panel/assets/{slide-DQ4lmUSz.js → slide-TcNt6K6l.js} +1 -1
  137. package/src/assets/web-panel/assets/{statusUtils-C1TR4ZiQ.js → statusUtils-mpTN9mrS.js} +1 -1
  138. package/src/assets/web-panel/assets/{styleChecker-B-Suj978.js → styleChecker-Bwg8zmIj.js} +1 -1
  139. package/src/assets/web-panel/assets/{useFlexGapSupport-Q7bDZ3EI.js → useFlexGapSupport-Cz4mN4sW.js} +1 -1
  140. package/src/assets/web-panel/assets/{useFs-DHjRLyQH.js → useFs-DF9zRfbI.js} +1 -1
  141. package/src/assets/web-panel/assets/{usePersonalDataHub-lQOvCvCr.js → usePersonalDataHub-BcQ4O2UK.js} +1 -1
  142. package/src/assets/web-panel/assets/{vnode-Ie0g4-p3.js → vnode-juM2dgAV.js} +1 -1
  143. package/src/assets/web-panel/assets/{zoom-CHP0YEoH.js → zoom-c4aJj-vC.js} +1 -1
  144. package/src/assets/web-panel/index.html +1 -1
  145. package/src/commands/agent.js +41 -2
  146. package/src/commands/compliance.js +3 -1
  147. package/src/commands/review.js +47 -17
  148. package/src/commands/session.js +11 -2
  149. package/src/gateways/ws/message-dispatcher.js +145 -165
  150. package/src/gateways/ws/ws-session-gateway.js +39 -5
  151. package/src/harness/jsonl-session-store.js +39 -12
  152. package/src/harness/worktree-isolator.js +5 -0
  153. package/src/lib/agent-core.js +1 -0
  154. package/src/lib/audit-logger.js +3 -1
  155. package/src/lib/chat-core.js +5 -2
  156. package/src/lib/chat-intent-service.js +68 -21
  157. package/src/lib/config-manager.js +25 -1
  158. package/src/lib/credential-guard.js +43 -1
  159. package/src/lib/git-integration.js +5 -0
  160. package/src/lib/interaction-adapter.js +32 -11
  161. package/src/lib/jsonl-session-store.js +1 -0
  162. package/src/lib/llm-config-defaults.js +37 -0
  163. package/src/lib/mcp-oauth.js +76 -10
  164. package/src/lib/permission-engine.js +4 -1
  165. package/src/lib/personal-data-hub-wiring.js +14 -0
  166. package/src/lib/pipeline-orchestrator.js +20 -2
  167. package/src/lib/project-detector.js +44 -3
  168. package/src/lib/project-instructions.js +13 -0
  169. package/src/lib/repl-denials.js +137 -0
  170. package/src/lib/safe-json.js +22 -0
  171. package/src/lib/sandbox-v2.js +7 -6
  172. package/src/lib/search-command.js +65 -0
  173. package/src/lib/settings-hooks.cjs +133 -0
  174. package/src/lib/settings-loader.cjs +16 -7
  175. package/src/lib/social-graph.js +3 -1
  176. package/src/lib/stream-retry.js +27 -0
  177. package/src/lib/turn-context.js +15 -5
  178. package/src/repl/agent-repl.js +81 -8
  179. package/src/runtime/__tests__/message-roles.test.js +36 -3
  180. package/src/runtime/agent-core.js +175 -50
  181. package/src/runtime/coding-agent-contract-shared.cjs +9 -2
  182. package/src/runtime/coding-agent-shell-policy.cjs +23 -2
  183. package/src/runtime/file-ref-expander.js +51 -7
  184. package/src/runtime/headless-runner.js +83 -2
  185. package/src/runtime/headless-stream.js +69 -3
  186. package/src/runtime/mcp-config.js +42 -1
  187. package/src/runtime/message-roles.js +14 -1
  188. package/src/assets/web-panel/assets/ChatBubbleRenderer-Pg7dIsiE.js +0 -1
  189. package/src/assets/web-panel/assets/TimelineRenderer-acXS5N5h.js +0 -1
  190. package/src/assets/web-panel/assets/devWarning-D_lwLZ6O.js +0 -1
  191. package/src/assets/web-panel/assets/index-CEwuCM44.js +0 -1
  192. package/src/assets/web-panel/assets/index-DzzgU4IU.js +0 -1
@@ -22,6 +22,40 @@ import { firstBalancedJson } from "./json-schema-output.js";
22
22
 
23
23
  const DEFAULT_INTENT_TIMEOUT_MS = 15000;
24
24
 
25
+ /**
26
+ * Resolve the per-call intent-classification budget. `llmOptions.intentTimeoutMs`
27
+ * overrides the default; an explicit 0 disables the cap; NaN/negative/absent →
28
+ * default.
29
+ */
30
+ function resolveIntentTimeout(llmOptions) {
31
+ const raw = llmOptions?.intentTimeoutMs;
32
+ if (raw === 0) return 0; // explicit disable
33
+ const n = Number(raw);
34
+ return Number.isFinite(n) && n > 0 ? n : DEFAULT_INTENT_TIMEOUT_MS;
35
+ }
36
+
37
+ /**
38
+ * Bound an LLM call to the intent budget. chat-core has its OWN stall guard, but
39
+ * it only fires on SILENCE (no bytes for 180s) — a slow trickling stream never
40
+ * trips it, so without this the declared 15s intent budget was never enforced
41
+ * and classification could block for minutes. Racing the deadline makes it fail
42
+ * fast into the caller's graceful rule/verbatim fallback. `ms <= 0` disables.
43
+ * The losing background request is harmless (chat-core releases its own socket);
44
+ * the timer is unref'd so it never holds the process open.
45
+ */
46
+ function withIntentTimeout(promise, ms) {
47
+ if (!(Number(ms) > 0)) return promise;
48
+ let timer;
49
+ const timeout = new Promise((_resolve, reject) => {
50
+ timer = setTimeout(
51
+ () => reject(new Error(`intent LLM call timed out after ${ms}ms`)),
52
+ Number(ms),
53
+ );
54
+ if (timer && typeof timer.unref === "function") timer.unref();
55
+ });
56
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
57
+ }
58
+
25
59
  /**
26
60
  * Build the V5 understandIntent system + user prompt pair.
27
61
  *
@@ -123,17 +157,20 @@ export async function understandIntent({
123
157
  );
124
158
 
125
159
  try {
126
- const fullContent = await chatWithStreaming(
127
- [
128
- { role: "system", content: systemPrompt },
129
- { role: "user", content: userPrompt },
130
- ],
131
- {
132
- ...llmOptions,
133
- // Lower temperature → more deterministic JSON output.
134
- temperature: 0.3,
135
- maxTokens: 500,
136
- },
160
+ const fullContent = await withIntentTimeout(
161
+ chatWithStreaming(
162
+ [
163
+ { role: "system", content: systemPrompt },
164
+ { role: "user", content: userPrompt },
165
+ ],
166
+ {
167
+ ...llmOptions,
168
+ // Lower temperature → more deterministic JSON output.
169
+ temperature: 0.3,
170
+ maxTokens: 500,
171
+ },
172
+ ),
173
+ resolveIntentTimeout(llmOptions),
137
174
  );
138
175
 
139
176
  const jsonText = extractJson(fullContent);
@@ -204,6 +241,11 @@ export async function* understandIntentStream({
204
241
  );
205
242
  let buffer = "";
206
243
  try {
244
+ // Incremental token yielding makes a per-call deadline awkward to apply
245
+ // here; this streaming variant is instead bounded by chat-core's own
246
+ // silence-based stall guard (CC_CHAT_STALL_MS, default 180s). The awaited
247
+ // understandIntent / classifyFollowupIntent paths enforce the tighter
248
+ // intent budget (resolveIntentTimeout) directly.
207
249
  for await (const event of chatStream(
208
250
  [
209
251
  { role: "system", content: systemPrompt },
@@ -452,16 +494,19 @@ ${
452
494
 
453
495
  async function llmBasedClassify(userInput, context, llmOptions) {
454
496
  const { systemPrompt, userPrompt } = buildFollowupPrompts(userInput, context);
455
- const fullContent = await chatWithStreaming(
456
- [
457
- { role: "system", content: systemPrompt },
458
- { role: "user", content: userPrompt },
459
- ],
460
- {
461
- ...llmOptions,
462
- temperature: 0.1,
463
- maxTokens: 300,
464
- },
497
+ const fullContent = await withIntentTimeout(
498
+ chatWithStreaming(
499
+ [
500
+ { role: "system", content: systemPrompt },
501
+ { role: "user", content: userPrompt },
502
+ ],
503
+ {
504
+ ...llmOptions,
505
+ temperature: 0.1,
506
+ maxTokens: 300,
507
+ },
508
+ ),
509
+ resolveIntentTimeout(llmOptions),
465
510
  );
466
511
  const jsonText = extractJson(fullContent);
467
512
  if (!jsonText) throw new Error("LLM response missing JSON");
@@ -540,5 +585,7 @@ export const _internal = {
540
585
  buildFollowupPrompts,
541
586
  extractJson,
542
587
  ruleBasedClassify,
588
+ resolveIntentTimeout,
589
+ withIntentTimeout,
543
590
  DEFAULT_INTENT_TIMEOUT_MS,
544
591
  };
@@ -11,6 +11,16 @@ import { getConfigPath } from "./paths.js";
11
11
  import { DEFAULT_CONFIG } from "../constants.js";
12
12
  import { withFileLock } from "./with-file-lock.js";
13
13
 
14
+ // Warn at most once per config path per process. Injectable seam: the default
15
+ // stays silent under vitest so test output isn't polluted; tests override
16
+ // `_deps.warn` to assert it fired.
17
+ const _warnedConfigPaths = new Set();
18
+ export const _deps = {
19
+ warn: (msg) => {
20
+ if (!process.env.VITEST) process.stderr.write(msg);
21
+ },
22
+ };
23
+
14
24
  export function loadConfig() {
15
25
  const configPath = getConfigPath();
16
26
  if (!existsSync(configPath)) {
@@ -20,7 +30,21 @@ export function loadConfig() {
20
30
  const raw = readFileSync(configPath, "utf-8");
21
31
  const parsed = JSON.parse(raw);
22
32
  return deepMerge(structuredClone(DEFAULT_CONFIG), parsed);
23
- } catch {
33
+ } catch (err) {
34
+ // The file EXISTS but couldn't be read/parsed (a typo, trailing comma, or
35
+ // truncated write). Silently returning defaults drops the user's entire
36
+ // config — provider, model, baseUrl, API key — and falls back to the
37
+ // default provider, producing a baffling "configured X but it runs Y / says
38
+ // no API key". Warn once so the failure is visible; still fall back so cc
39
+ // keeps working.
40
+ if (!_warnedConfigPaths.has(configPath)) {
41
+ _warnedConfigPaths.add(configPath);
42
+ _deps.warn(
43
+ `⚠️ Could not read config at ${configPath} (${err.message}). ` +
44
+ `Using defaults — your saved settings (provider / model / API key) are being IGNORED. ` +
45
+ `Fix the JSON to restore them.\n`,
46
+ );
47
+ }
24
48
  return { ...structuredClone(DEFAULT_CONFIG) };
25
49
  }
26
50
  }
@@ -149,7 +149,10 @@ export function credentialFileReasonResolved(targetPath, opts = {}) {
149
149
  // ── secret ENV-VAR + command detection ──────────────────────────────────────
150
150
 
151
151
  // Content-reader commands: when one of these is aimed at a credential file, the
152
- // file's bytes land in the agent's tool output.
152
+ // file's bytes land in the agent's tool output. Includes text processors
153
+ // (awk/sed), encoders (base64), and field/byte slicers — all of which dump a
154
+ // file's content just like `cat`, so an agent told to "read the .env" can't
155
+ // route around the guard with `base64 .env` / `awk '{print}' .env`.
153
156
  const READ_COMMANDS = new Set([
154
157
  "cat",
155
158
  "tac",
@@ -165,8 +168,31 @@ const READ_COMMANDS = new Set([
165
168
  "xxd",
166
169
  "od",
167
170
  "strings",
171
+ "awk",
172
+ "gawk",
173
+ "mawk",
174
+ "sed",
175
+ "base64",
176
+ "base32",
177
+ "cut",
178
+ "rev",
179
+ "sort",
180
+ "uniq",
181
+ "tr",
182
+ "fold",
183
+ "expand",
184
+ "unexpand",
185
+ "column",
186
+ "paste",
187
+ "pr",
168
188
  ]);
169
189
 
190
+ // grep-family is a content-reader too, but its FIRST non-flag arg is the search
191
+ // PATTERN (which may itself look credential-ish, e.g. `grep "id_rsa" notes.txt`)
192
+ // — so it is handled separately, checking only the FILE args after the pattern,
193
+ // to keep the guard precise (no false positive on the pattern).
194
+ const GREP_COMMANDS = new Set(["grep", "egrep", "fgrep", "rg", "ag"]);
195
+
170
196
  // Commands whose job is to print a value into output — the exfil surface for a
171
197
  // secret env var (a tool that merely CONSUMES `$API_KEY` does not echo it).
172
198
  const PRINT_COMMANDS = new Set([
@@ -281,6 +307,22 @@ export function commandReadsCredentials(command) {
281
307
  }
282
308
  }
283
309
 
310
+ // (b2) grep-family: skip the search pattern (first non-flag arg) and check
311
+ // only the file args, so `grep KEY .env` is caught but `grep "id_rsa"
312
+ // notes.txt` (searching FOR that text) is not a false positive.
313
+ if (GREP_COMMANDS.has(first)) {
314
+ for (const tok of nonFlagArgs.slice(1)) {
315
+ const r = credentialFileReason(tok);
316
+ if (r)
317
+ return {
318
+ reason: `reads ${r}`,
319
+ kind: "file",
320
+ target: tok,
321
+ segment: seg,
322
+ };
323
+ }
324
+ }
325
+
284
326
  // (c) printing / reading a secret-looking env var (via $VAR / %VAR% / env:).
285
327
  if (PRINT_COMMANDS.has(first) || READ_COMMANDS.has(first)) {
286
328
  const vars = referencedSecretVars(seg);
@@ -105,6 +105,11 @@ export function gitExec(args, cwd) {
105
105
  return execSync(`git ${args}`, {
106
106
  cwd,
107
107
  encoding: "utf-8",
108
+ // git diff/log/worktree-list output on a large repo easily exceeds
109
+ // execSync's 1 MB default → ENOBUFS. Match gitExecArgs' 64 MB ceiling so
110
+ // a big diff/log doesn't spuriously fail (e.g. worktree-diff's --numstat
111
+ // pass, which isn't wrapped in a degrade-gracefully try/catch).
112
+ maxBuffer: 64 * 1024 * 1024,
108
113
  stdio: ["pipe", "pipe", "pipe"],
109
114
  }).trim();
110
115
  } catch (err) {
@@ -159,7 +159,15 @@ export class WebSocketInteractionAdapter extends InteractionAdapter {
159
159
  },
160
160
  options.timeoutMs || 5 * 60 * 1000,
161
161
  );
162
- this._pending.set(requestId, { resolve, reject, timeoutId });
162
+ // `kind` tags what KIND of response settles this request ("question" vs
163
+ // "host-tool"). Checked in _resolvePending so a peer's session-answer can
164
+ // never resolve a host-tool call with a plain string (or vice versa).
165
+ this._pending.set(requestId, {
166
+ resolve,
167
+ reject,
168
+ timeoutId,
169
+ kind: options.kind || null,
170
+ });
163
171
 
164
172
  this._sendWs({
165
173
  ...message,
@@ -170,12 +178,15 @@ export class WebSocketInteractionAdapter extends InteractionAdapter {
170
178
  }
171
179
 
172
180
  _ask(questionType, question, extra = {}) {
173
- return this._request({
174
- type: "question",
175
- questionType,
176
- question,
177
- ...extra,
178
- });
181
+ return this._request(
182
+ {
183
+ type: "question",
184
+ questionType,
185
+ question,
186
+ ...extra,
187
+ },
188
+ { kind: "question" },
189
+ );
179
190
  }
180
191
 
181
192
  async askInput(question, options = {}) {
@@ -200,7 +211,7 @@ export class WebSocketInteractionAdapter extends InteractionAdapter {
200
211
  * Resolves the corresponding pending promise.
201
212
  */
202
213
  resolveAnswer(requestId, answer) {
203
- this._resolvePending(requestId, answer);
214
+ this._resolvePending(requestId, answer, "question");
204
215
  }
205
216
 
206
217
  async requestHostTool(toolName, args = {}, extra = {}) {
@@ -211,12 +222,12 @@ export class WebSocketInteractionAdapter extends InteractionAdapter {
211
222
  args,
212
223
  ...extra,
213
224
  },
214
- { timeoutMs: extra.timeoutMs || 60 * 1000 },
225
+ { timeoutMs: extra.timeoutMs || 60 * 1000, kind: "host-tool" },
215
226
  );
216
227
  }
217
228
 
218
229
  resolveHostTool(requestId, payload) {
219
- this._resolvePending(requestId, payload);
230
+ this._resolvePending(requestId, payload, "host-tool");
220
231
  }
221
232
 
222
233
  rejectAllPending(reason = createAbortError("Interaction interrupted")) {
@@ -310,11 +321,21 @@ export class WebSocketInteractionAdapter extends InteractionAdapter {
310
321
  }
311
322
  }
312
323
 
313
- _resolvePending(requestId, payload) {
324
+ _resolvePending(requestId, payload, expectedKind = null) {
314
325
  const pending = this._pending.get(requestId);
315
326
  if (!pending) {
316
327
  return;
317
328
  }
329
+ // Kind guard: a question-answer must not settle a host-tool request (or
330
+ // vice versa). On a mismatch, IGNORE the message — leave the request pending
331
+ // so its correct resolver (or its timeout) can still settle it. Prevents a
332
+ // peer from resolving a host-tool call with a plain question string, or a
333
+ // question with a structured tool payload (type confusion of the awaited
334
+ // value). Backward-compatible: callers passing no expectedKind, and
335
+ // pre-kind pending entries, behave exactly as before.
336
+ if (expectedKind && pending.kind && pending.kind !== expectedKind) {
337
+ return;
338
+ }
318
339
 
319
340
  this._pending.delete(requestId);
320
341
  if (pending.timeoutId) {
@@ -32,4 +32,5 @@ export {
32
32
  sessionPath,
33
33
  isUnsafeSessionId,
34
34
  appendTokenUsage,
35
+ toIsoSafe,
35
36
  } from "../harness/jsonl-session-store.js";
@@ -17,6 +17,43 @@
17
17
  * lesson as the --settings "opus"→404 vision trap.
18
18
  * - No configured provider → unchanged (runner defaults apply: ollama).
19
19
  */
20
+ /**
21
+ * Reconcile a MISLABELED llm config/options object. The `baseUrl` is the
22
+ * authoritative signal of which provider you actually reach, so when `provider`
23
+ * disagrees with the provider its `baseUrl` belongs to, the config is simply
24
+ * mislabeled — the recurring "provider:anthropic + volces baseUrl + model:haiku"
25
+ * corruption that makes runnable-provider relabel (and emit "provider 配置与
26
+ * baseUrl 不一致") on EVERY run because the on-disk config never gets fixed.
27
+ *
28
+ * Correct `provider` to match the endpoint, and if the model is foreign to the
29
+ * corrected provider, replace it with that provider's default. Pure: the input
30
+ * is never mutated — returns `{ llm, changed }` where `llm` is the original
31
+ * object (changed:false) or a corrected shallow copy (changed:true). Works for
32
+ * both a `config.llm` block and a resolved request-`options` (same shape).
33
+ *
34
+ * `inferProviderFromBaseUrl` returns null for unknown/custom hosts (proxies),
35
+ * so an intentionally-custom endpoint is left untouched.
36
+ */
37
+ export async function reconcileConfigLlmProvider(cfgLlm = {}) {
38
+ if (!cfgLlm || !cfgLlm.provider || !cfgLlm.baseUrl) {
39
+ return { llm: cfgLlm, changed: false };
40
+ }
41
+ const { inferProviderFromBaseUrl, modelForeignToProvider } =
42
+ await import("./runnable-provider.js");
43
+ const inferred = inferProviderFromBaseUrl(cfgLlm.baseUrl);
44
+ if (!inferred || inferred === cfgLlm.provider) {
45
+ return { llm: cfgLlm, changed: false };
46
+ }
47
+ const corrected = { ...cfgLlm, provider: inferred };
48
+ if (modelForeignToProvider(inferred, cfgLlm.model)) {
49
+ const { selectModelForTask, TaskType } =
50
+ await import("./task-model-selector.js");
51
+ const def = selectModelForTask(inferred, TaskType.CHAT);
52
+ if (def) corrected.model = def;
53
+ }
54
+ return { llm: corrected, changed: true };
55
+ }
56
+
20
57
  export function applyConfigLlmDefaults(options = {}, cfgLlm = {}, opts = {}) {
21
58
  // The IDE chat panel pins --provider/--model (read from config) but DROPS
22
59
  // --base-url/--api-key. With an explicit --provider the block below bails, so
@@ -38,6 +38,10 @@ export const _deps = {
38
38
  now: () => Date.now(),
39
39
  // Backoff sleep seam (tests override with a no-op so the retry doesn't wait).
40
40
  sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
41
+ // Visible-warning seam (silent under vitest; tests override to assert).
42
+ warn: (msg) => {
43
+ if (!process.env.VITEST) process.stderr.write(msg);
44
+ },
41
45
  };
42
46
 
43
47
  /** Discovery/token requests retry ONCE after a transient error (CC 2.1.191). */
@@ -371,17 +375,56 @@ export function tokenStorePath() {
371
375
  return pathDefault.join(_deps.homedir(), ".chainlesschain", "mcp-oauth.json");
372
376
  }
373
377
 
374
- export function loadTokenStore() {
375
- const file = tokenStorePath();
378
+ /**
379
+ * Read the token store, distinguishing "absent / empty" (ok) from "exists but
380
+ * unreadable / corrupt" (ok:false). The distinction matters on the WRITE path:
381
+ * a read-modify-write that treats an unreadable store as `{}` would overwrite
382
+ * and silently destroy every other server's token.
383
+ */
384
+ function _readStore(file) {
385
+ if (!_deps.fs.existsSync(file)) return { ok: true, store: {} };
376
386
  try {
377
- if (!_deps.fs.existsSync(file)) return {};
378
387
  const data = JSON.parse(_deps.fs.readFileSync(file, "utf-8"));
379
- return data && typeof data === "object" ? data : {};
388
+ return { ok: true, store: data && typeof data === "object" ? data : {} };
389
+ } catch (err) {
390
+ return { ok: false, store: {}, err };
391
+ }
392
+ }
393
+
394
+ const _warnedCorruptStore = new Set();
395
+ function _warnCorruptStoreOnce(file, err, archived) {
396
+ if (_warnedCorruptStore.has(file)) return;
397
+ _warnedCorruptStore.add(file);
398
+ _deps.warn(
399
+ `⚠️ MCP OAuth token store at ${file} is unreadable (${err?.message || "parse error"}). ` +
400
+ `Saved MCP logins are unavailable${archived ? `; the unreadable file was preserved as ${archived}` : ""} — ` +
401
+ "re-run `cc mcp login <url>` for the servers you use.\n",
402
+ );
403
+ }
404
+
405
+ /** Move an unreadable store aside so a fresh write doesn't destroy it. Returns
406
+ * the archive path, or "" when it couldn't be moved (best-effort). */
407
+ function _archiveCorruptStore(file) {
408
+ if (typeof _deps.fs.renameSync !== "function") return "";
409
+ const ts = typeof _deps.now === "function" ? _deps.now() : Date.now();
410
+ const dest = `${file}.corrupt-${ts}`;
411
+ try {
412
+ _deps.fs.renameSync(file, dest);
413
+ return dest;
380
414
  } catch {
381
- return {};
415
+ return "";
382
416
  }
383
417
  }
384
418
 
419
+ export function loadTokenStore() {
420
+ const file = tokenStorePath();
421
+ const r = _readStore(file);
422
+ // Read callers (getStoredToken) treat an unreadable store as "no token", but
423
+ // the user should still know their saved logins silently vanished.
424
+ if (!r.ok) _warnCorruptStoreOnce(file, r.err, "");
425
+ return r.store;
426
+ }
427
+
385
428
  export function getStoredToken(serverUrl) {
386
429
  return loadTokenStore()[serverKey(serverUrl)] || null;
387
430
  }
@@ -527,7 +570,15 @@ function withStoreLock(file, fn) {
527
570
  export function saveStoredToken(serverUrl, record) {
528
571
  const file = tokenStorePath();
529
572
  return withStoreLock(file, () => {
530
- const store = loadTokenStore(); // re-read inside the lock (latest state)
573
+ const r = _readStore(file); // re-read inside the lock (latest state)
574
+ if (!r.ok) {
575
+ // The existing store is unreadable. Writing a fresh single-entry store
576
+ // here would silently destroy every other server's token — move the
577
+ // unreadable file aside first so it can be recovered, and surface it.
578
+ const archived = _archiveCorruptStore(file);
579
+ _warnCorruptStoreOnce(file, r.err, archived);
580
+ }
581
+ const store = r.store;
531
582
  store[serverKey(serverUrl)] = { server: serverKey(serverUrl), ...record };
532
583
  _writeStoreSecure(file, store);
533
584
  return store[serverKey(serverUrl)];
@@ -560,13 +611,28 @@ export function isTokenExpired(record, { skewMs = 60_000 } = {}) {
560
611
  /**
561
612
  * Return a valid bearer token for a server, refreshing if expired. Returns the
562
613
  * access_token string, or null if there's no stored token / refresh failed.
614
+ *
615
+ * `forceRefresh` bypasses the local-expiry check and exchanges the refresh
616
+ * token unconditionally. Use it when the server REJECTED a stored token that
617
+ * still looked unexpired locally (mid-session 401/403 from a rotated/revoked
618
+ * grant): the cached access_token is known-bad, so reusing it is pointless —
619
+ * we either mint a fresh one or return null to signal "re-login needed".
620
+ *
621
+ * @param {string} serverUrl
622
+ * @param {{ forceRefresh?: boolean }} [opts]
563
623
  */
564
- export async function ensureValidToken(serverUrl) {
624
+ export async function ensureValidToken(
625
+ serverUrl,
626
+ { forceRefresh = false } = {},
627
+ ) {
565
628
  const record = getStoredToken(serverUrl);
566
629
  if (!record) return null;
567
- if (!isTokenExpired(record)) return record.access_token;
630
+ if (!forceRefresh && !isTokenExpired(record)) return record.access_token;
568
631
  if (!record.refresh_token || !record.endpoints?.token_endpoint) {
569
- return record.access_token || null; // can't refresh use what we have
632
+ // Can't refresh. On a forced refresh the cached token is known-rejected, so
633
+ // hand back null (caller should stop retrying and prompt re-login); on the
634
+ // proactive path keep the existing behaviour of using what we have.
635
+ return forceRefresh ? null : record.access_token || null;
570
636
  }
571
637
  try {
572
638
  const tok = await refreshAccessToken(
@@ -576,7 +642,7 @@ export async function ensureValidToken(serverUrl) {
576
642
  const updated = saveStoredToken(serverUrl, { ...record, ...tok });
577
643
  return updated.access_token;
578
644
  } catch {
579
- return record.access_token || null;
645
+ return forceRefresh ? null : record.access_token || null;
580
646
  }
581
647
  }
582
648
 
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  import crypto from "crypto";
7
+ import { safeJsonParse } from "./safe-json.js";
7
8
 
8
9
  function generateId() {
9
10
  return crypto.randomUUID();
@@ -160,7 +161,9 @@ export function getRoles(db) {
160
161
  .all();
161
162
  return rows.map((r) => ({
162
163
  ...r,
163
- permissions: JSON.parse(r.permissions || "[]"),
164
+ // `|| "[]"` only guarded null — a corrupt permissions cell still threw out
165
+ // of the whole role list (and could break permission checks). Skip it.
166
+ permissions: safeJsonParse(r.permissions, []),
164
167
  isBuiltin: r.is_builtin === 1,
165
168
  }));
166
169
  }
@@ -62,6 +62,7 @@ let LocalVault,
62
62
  SystemDataAndroidAdapter,
63
63
  BrowserHistoryChromeAdapter,
64
64
  BrowserHistoryEdgeAdapter,
65
+ BrowserHistoryAospAdapter,
65
66
  VSCodeAdapter,
66
67
  WinRecentAdapter,
67
68
  GitActivityAdapter,
@@ -184,6 +185,7 @@ function ensurePdhLoaded() {
184
185
  SystemDataAndroidAdapter,
185
186
  BrowserHistoryChromeAdapter,
186
187
  BrowserHistoryEdgeAdapter,
188
+ BrowserHistoryAospAdapter,
187
189
  VSCodeAdapter,
188
190
  WinRecentAdapter,
189
191
  GitActivityAdapter,
@@ -626,6 +628,18 @@ async function initHub() {
626
628
  // Continue boot
627
629
  }
628
630
 
631
+ // AOSP / MIUI stock browser (com.android.browser → browser2.db). No host
632
+ // default — needs a device-pulled browser2.db via opts.dbPath at sync time
633
+ // (cc hub sync-adapter browser-history-aosp --db-path <pulled>), so bare
634
+ // registration is inert (healthCheck ok:false) until input is supplied,
635
+ // exactly like the snapshot adapters.
636
+ try {
637
+ const aospBrowser = new BrowserHistoryAospAdapter();
638
+ if (!registry.has(aospBrowser.name)) registry.register(aospBrowser);
639
+ } catch (_err) {
640
+ // Continue boot
641
+ }
642
+
629
643
  // VS Code workspace history + global terminal history. Reads
630
644
  // %APPDATA%\Code\User\workspaceStorage and \globalStorage\state.vscdb
631
645
  // on Win; equivalents on macOS/Linux.
@@ -553,7 +553,25 @@ export function retryStage(db, pipelineId, stageIndex) {
553
553
  const pipeline = _getPipelineRow(db, pipelineId);
554
554
  if (!pipeline) throw new Error(`Pipeline not found: ${pipelineId}`);
555
555
 
556
- const stage = _getStageRow(db, pipelineId, stageIndex);
556
+ // A retry may only re-open the CURRENT stage or an EARLIER one. A forward
557
+ // jump would move current_stage past the intervening stages — skipping their
558
+ // approval GATES (CODE_REVIEW / DEPLOY) — and let a caller (`cc pipeline retry
559
+ // --stage N`, user-supplied) reach a later stage without the gates that guard
560
+ // it. Going backward is safe: advancing from there re-runs through every gate
561
+ // again. Mirrors the state-machine rigor of completeStage / approveGate.
562
+ const idx = Number(stageIndex);
563
+ if (!Number.isInteger(idx) || idx < 0) {
564
+ throw new Error(`Invalid stage index: ${stageIndex}`);
565
+ }
566
+ if (idx > pipeline.current_stage) {
567
+ throw new Error(
568
+ `Cannot retry stage ${idx}: it is ahead of the current stage ` +
569
+ `(${pipeline.current_stage}) — retrying forward would skip the ` +
570
+ `intervening stages and their approval gates`,
571
+ );
572
+ }
573
+
574
+ const stage = _getStageRow(db, pipelineId, idx);
557
575
  if (!stage) throw new Error(`Stage ${stageIndex} not found`);
558
576
 
559
577
  const now = _now();
@@ -572,7 +590,7 @@ export function retryStage(db, pipelineId, stageIndex) {
572
590
  });
573
591
  _updatePipelineFields(db, pipelineId, {
574
592
  status: PIPELINE_STATUS.RUNNING,
575
- current_stage: stageIndex,
593
+ current_stage: idx,
576
594
  error_message: null,
577
595
  completed_at: null,
578
596
  });
@@ -28,19 +28,60 @@ export function findProjectRoot(startDir) {
28
28
  return null;
29
29
  }
30
30
 
31
+ // mtime-keyed parse cache. `config.json` is loaded on EVERY agent tool call
32
+ // (via _loadProjectPersona) and every buildSystemPrompt, so re-reading +
33
+ // re-parsing it each time is wasted syscalls + parse work on the agent hot
34
+ // path. We cache the parsed object keyed by absolute configPath and invalidate
35
+ // when the file's mtimeMs changes — so an external edit or a `persona set`
36
+ // write (which rewrites the file → new mtime) is always picked up on the next
37
+ // call. A cache HIT costs one statSync instead of readFileSync + JSON.parse.
38
+ //
39
+ // The cached object is SHARED across callers — treat the return value as
40
+ // READ-ONLY. Every current caller only reads it; `persona set` reads+writes the
41
+ // file directly (bypassing this loader), so it never aliases the cache.
42
+ const _configCache = new Map(); // configPath -> { mtimeMs, value }
43
+
31
44
  /**
32
45
  * Load project configuration from .chainlesschain/config.json
46
+ *
47
+ * The returned object is cached and shared — do not mutate it. To change the
48
+ * config, read+write the file directly (see `cc persona set`).
49
+ *
33
50
  * @param {string} projectRoot - Project root directory
34
- * @returns {object|null} Parsed config or null on error
51
+ * @returns {object|null} Parsed config (READ-ONLY) or null on error
35
52
  */
36
53
  export function loadProjectConfig(projectRoot) {
37
54
  const configPath = path.join(projectRoot, ".chainlesschain", "config.json");
55
+ let mtimeMs;
38
56
  try {
39
- const content = fs.readFileSync(configPath, "utf-8");
40
- return JSON.parse(content);
57
+ mtimeMs = fs.statSync(configPath).mtimeMs;
41
58
  } catch {
59
+ // Missing / unreadable → no config. Drop any stale entry so a file that
60
+ // reappears later is re-read rather than served from a dead cache slot.
61
+ _configCache.delete(configPath);
42
62
  return null;
43
63
  }
64
+ const cached = _configCache.get(configPath);
65
+ if (cached && cached.mtimeMs === mtimeMs) return cached.value;
66
+ let value;
67
+ try {
68
+ value = JSON.parse(fs.readFileSync(configPath, "utf-8"));
69
+ } catch {
70
+ // Malformed JSON / read race → null (same as before). Cache it against the
71
+ // current mtime so a persistently-broken file isn't re-read every tool call;
72
+ // a later fix bumps mtime and invalidates this entry.
73
+ value = null;
74
+ }
75
+ _configCache.set(configPath, { mtimeMs, value });
76
+ return value;
77
+ }
78
+
79
+ /**
80
+ * Test seam: clear the loadProjectConfig mtime cache. Not used in production —
81
+ * the cache is process-lifetime and self-invalidates on mtime change.
82
+ */
83
+ export function _clearProjectConfigCache() {
84
+ _configCache.clear();
44
85
  }
45
86
 
46
87
  /**