chainlesschain 0.162.162 → 0.162.164
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.
- package/package.json +3 -1
- package/src/assets/web-panel/assets/{AIOps-ClI2ALgv.js → AIOps-BqRkaHGb.js} +1 -1
- package/src/assets/web-panel/assets/{ActionButton-BDjRcJaK.js → ActionButton-DWO7OZLC.js} +1 -1
- package/src/assets/web-panel/assets/{Analytics-DikSRsaT.js → Analytics-C5oaVezM.js} +3 -3
- package/src/assets/web-panel/assets/{AppLayout-9mLhMFmz.js → AppLayout-CsUESDoZ.js} +3 -3
- package/src/assets/web-panel/assets/{Artifacts-BSr88BrH.js → Artifacts-Dragqu5k.js} +1 -1
- package/src/assets/web-panel/assets/{Audit-CdvyimOF.js → Audit-CQsaHctY.js} +1 -1
- package/src/assets/web-panel/assets/{BackgroundAgents-D3_hCedh.js → BackgroundAgents-DdG1jNSM.js} +1 -1
- package/src/assets/web-panel/assets/{Backup-Cyvv9RHf.js → Backup-DN6sjK4z.js} +1 -1
- package/src/assets/web-panel/assets/{BaseInput-BZM14kt8.js → BaseInput-Cxm_ZTCl.js} +1 -1
- package/src/assets/web-panel/assets/{Chat-DrQSokVz.js → Chat-DVXGmA56.js} +6 -6
- package/src/assets/web-panel/assets/{ChatBubbleRenderer-HwJR-C6B.js → ChatBubbleRenderer-BdaCL_iE.js} +1 -1
- package/src/assets/web-panel/assets/{Checkbox-bFLYeWMV.js → Checkbox-DEc4vD_A.js} +1 -1
- package/src/assets/web-panel/assets/{Codegen-7mN7xJXT.js → Codegen-CKiTcv36.js} +1 -1
- package/src/assets/web-panel/assets/{Col-DAB5ZWm7.js → Col-Br9UUNN0.js} +1 -1
- package/src/assets/web-panel/assets/{Community-B_1TymaK.js → Community-NeUH_70T.js} +1 -1
- package/src/assets/web-panel/assets/{Compact-Bq1uSWLJ.js → Compact-BqR-z2Df.js} +1 -1
- package/src/assets/web-panel/assets/{Compliance-CyiE1jEe.js → Compliance-DcWB_SgZ.js} +1 -1
- package/src/assets/web-panel/assets/{Cowork-Cpk0D7vm.js → Cowork-BfhgcudF.js} +2 -2
- package/src/assets/web-panel/assets/{Cron-d_Lv3rdB.js → Cron-LaU29LgD.js} +2 -2
- package/src/assets/web-panel/assets/{Crosschain-DMqhHjMR.js → Crosschain-B2ZB9uwW.js} +1 -1
- package/src/assets/web-panel/assets/{DID-Br-oPuBw.js → DID-BuHl38A2.js} +2 -2
- package/src/assets/web-panel/assets/{Dashboard-CfLW_RaP.js → Dashboard-m627yqvZ.js} +2 -2
- package/src/assets/web-panel/assets/{Dropdown-DZnTOExV.js → Dropdown-C7bxF8F5.js} +1 -1
- package/src/assets/web-panel/assets/{EmailListRenderer-DHIUHDv1.js → EmailListRenderer-DPLPQzLp.js} +1 -1
- package/src/assets/web-panel/assets/{FamilyGuardDashboard-Bw3t3myu.js → FamilyGuardDashboard-cOiH6ike.js} +1 -1
- package/src/assets/web-panel/assets/{Federation-D9cpQPAY.js → Federation-Bvkzg88z.js} +1 -1
- package/src/assets/web-panel/assets/{FormItemContext-Jdczr9Mr.js → FormItemContext-BTsiA7h_.js} +1 -1
- package/src/assets/web-panel/assets/{GenericCardRenderer-BT8ZMpC5.js → GenericCardRenderer-BGnVE5qu.js} +1 -1
- package/src/assets/web-panel/assets/{Git-DjARip2b.js → Git-DS_j6r25.js} +2 -2
- package/src/assets/web-panel/assets/{Governance-BgWbSRcj.js → Governance-CKRG3Rsl.js} +1 -1
- package/src/assets/web-panel/assets/{Inference-CfouJciM.js → Inference-DW8INoNQ.js} +1 -1
- package/src/assets/web-panel/assets/{KnowledgeGraph-50l7mrAr.js → KnowledgeGraph-BhEYeFhC.js} +1 -1
- package/src/assets/web-panel/assets/{Logs-rq50H84B.js → Logs-wqi0uxuR.js} +2 -2
- package/src/assets/web-panel/assets/{MarkdownRenderer-DUPIEdQH.js → MarkdownRenderer-DS8dE8ty.js} +1 -1
- package/src/assets/web-panel/assets/{Marketplace-BYgPDmHU.js → Marketplace-CMlTuPLT.js} +1 -1
- package/src/assets/web-panel/assets/{McpTools-5DVgcFdx.js → McpTools-CSx4m5JJ.js} +3 -3
- package/src/assets/web-panel/assets/{Memory-B70LPk7f.js → Memory-vnQCUy-_.js} +2 -2
- package/src/assets/web-panel/assets/{MobileBridge-De8ssA5Y.js → MobileBridge-BV2NA742.js} +1 -1
- package/src/assets/web-panel/assets/MobileProjects-Bw0k8e1B.js +1 -0
- package/src/assets/web-panel/assets/{Mtc-RJsOTsIA.js → Mtc-CzK_FPB1.js} +2 -2
- package/src/assets/web-panel/assets/{MtcAudit-Crh-6BEK.js → MtcAudit-Cb7G9oL3.js} +5 -5
- package/src/assets/web-panel/assets/{Multisig-wuoR5l3x.js → Multisig-qJxlUyzr.js} +3 -3
- package/src/assets/web-panel/assets/{NLProgramming-U2Mji4fZ.js → NLProgramming-DqaBJ7Te.js} +1 -1
- package/src/assets/web-panel/assets/{Notes-CdTZAyDH.js → Notes-CwhTJkRi.js} +3 -3
- package/src/assets/web-panel/assets/{NotificationSettings-C-FucWE7.js → NotificationSettings-YDs4Fal4.js} +1 -1
- package/src/assets/web-panel/assets/{OrderTableRenderer-DCYFTeaI.js → OrderTableRenderer-CvEALpq7.js} +1 -1
- package/src/assets/web-panel/assets/{Organization-an3OF85L.js → Organization-CEmQm9n8.js} +4 -4
- package/src/assets/web-panel/assets/{Overflow-C-1vmYFe.js → Overflow-zby_P_LA.js} +1 -1
- package/src/assets/web-panel/assets/{P2P-Cwh47xKg.js → P2P-DsmDNFNT.js} +2 -2
- package/src/assets/web-panel/assets/{PdhVaultBrowser-Dm38GT0k.js → PdhVaultBrowser-FWwFlZGF.js} +3 -3
- package/src/assets/web-panel/assets/{Permissions-DS2Y8awD.js → Permissions-DuzQ6me5.js} +4 -4
- package/src/assets/web-panel/assets/{PersonalDataHub-BQNziA0I.js → PersonalDataHub-DZbeN613.js} +3 -3
- package/src/assets/web-panel/assets/{Pipeline-DF85L1ue.js → Pipeline-CoNSVAUS.js} +1 -1
- package/src/assets/web-panel/assets/{Privacy-BuLornF-.js → Privacy-OYJEAaMC.js} +1 -1
- package/src/assets/web-panel/assets/{ProjectInit-C3Y31IwX.js → ProjectInit-CWIapV--.js} +2 -2
- package/src/assets/web-panel/assets/{ProjectSettings-BCPiI8aD.js → ProjectSettings-CX8xMxZn.js} +2 -2
- package/src/assets/web-panel/assets/{Projects-BZ6s4k6g.js → Projects-BLCN8YyY.js} +1 -1
- package/src/assets/web-panel/assets/{Providers-D3Wp022X.js → Providers-BV_sxp75.js} +1 -1
- package/src/assets/web-panel/assets/{QrScannerModal-CUJfQkH4.js → QrScannerModal-BTnRrHRp.js} +1 -1
- package/src/assets/web-panel/assets/{QuickAsk-UAc0EO8l.js → QuickAsk-D1KZg018.js} +1 -1
- package/src/assets/web-panel/assets/{Recommend-BaxKupcs.js → Recommend-COm5cuNg.js} +1 -1
- package/src/assets/web-panel/assets/{RemoteSession-C75NNnXZ.js → RemoteSession-0saWEkYI.js} +2 -2
- package/src/assets/web-panel/assets/{Reputation-BVXynZAk.js → Reputation-Dr7iKLS2.js} +1 -1
- package/src/assets/web-panel/assets/{Row-C5MRZ0bi.js → Row-C1vpPBcr.js} +1 -1
- package/src/assets/web-panel/assets/{RssFeed-DAKkEYka.js → RssFeed-CxlWrtNj.js} +3 -3
- package/src/assets/web-panel/assets/{Search-DvWv9YOA.js → Search-BT2V8dPN.js} +1 -1
- package/src/assets/web-panel/assets/{Security-B5peafW1.js → Security-bm9V3c5k.js} +4 -4
- package/src/assets/web-panel/assets/{Services-UQmvPEaG.js → Services-CocH1yyX.js} +2 -2
- package/src/assets/web-panel/assets/{Skeleton-C3bhWpuF.js → Skeleton-DHItlMdd.js} +1 -1
- package/src/assets/web-panel/assets/{Skills-DfkKf9_H.js → Skills-C59sLc4w.js} +1 -1
- package/src/assets/web-panel/assets/{Sla-DqMSjlBM.js → Sla-Crj4BMc8.js} +1 -1
- package/src/assets/web-panel/assets/{SpeechSettings-C-WbxWUX.js → SpeechSettings-DX4KFceY.js} +1 -1
- package/src/assets/web-panel/assets/{SyncSettings-CYuHZ4N2.js → SyncSettings-BD_iwQLr.js} +2 -2
- package/src/assets/web-panel/assets/{Tasks-CyeAyB9d.js → Tasks-BmCTzYsH.js} +1 -1
- package/src/assets/web-panel/assets/{Templates-BTCeQnpq.js → Templates-BGUaxZYl.js} +1 -1
- package/src/assets/web-panel/assets/{Tenant-BifQuA9Y.js → Tenant-BS0tJx2O.js} +1 -1
- package/src/assets/web-panel/assets/{Terminal-Bx_9Bocf.js → Terminal--tHSAsld.js} +2 -2
- package/src/assets/web-panel/assets/{TimelineRenderer-BX0RFCOm.js → TimelineRenderer-D9-MFxS4.js} +1 -1
- package/src/assets/web-panel/assets/{Tokens-CGHrCmUS.js → Tokens-CiCB485f.js} +1 -1
- package/src/assets/web-panel/assets/{Trigger-DlqaY4SO.js → Trigger-SmCFuW_l.js} +1 -1
- package/src/assets/web-panel/assets/{Trust-DfOmj-ay.js → Trust-DkK3Rqe0.js} +1 -1
- package/src/assets/web-panel/assets/{UkeySign-B72jH5fE.js → UkeySign-CDM4LGdB.js} +1 -1
- package/src/assets/web-panel/assets/{VideoEditing-BT-63fqv.js → VideoEditing-IDB7DJXt.js} +1 -1
- package/src/assets/web-panel/assets/{Wallet-Dsul3sHr.js → Wallet-Bikj5cmt.js} +4 -4
- package/src/assets/web-panel/assets/{WebAuthn-Izkwwdgy.js → WebAuthn-C9abWlAw.js} +2 -2
- package/src/assets/web-panel/assets/{WorkflowEditor-D3FHSiH1.js → WorkflowEditor-DrbRKVky.js} +1 -1
- package/src/assets/web-panel/assets/{chat-CmVz5z3U.js → chat-CccCSDQf.js} +1 -1
- package/src/assets/web-panel/assets/{colors-CuLgyJBp.js → colors-CXObXLhb.js} +1 -1
- package/src/assets/web-panel/assets/{compact-item-Ml1YkXpw.js → compact-item-B5kfYAxC.js} +1 -1
- package/src/assets/web-panel/assets/{createContext-BvjJ48bs.js → createContext-DmiPBfip.js} +1 -1
- package/src/assets/web-panel/assets/devWarning-CvKrADNa.js +1 -0
- package/src/assets/web-panel/assets/{hasIn-DVC0157C.js → hasIn-Q6lYteAc.js} +1 -1
- package/src/assets/web-panel/assets/{index-C229ABfy.js → index-25Wlqps4.js} +1 -1
- package/src/assets/web-panel/assets/{index-Ck_52dz_.js → index-33U29UNV.js} +1 -1
- package/src/assets/web-panel/assets/{index-QXzlT9KD.js → index-4oXBDczR.js} +1 -1
- package/src/assets/web-panel/assets/{index-4SBta3d7.js → index-BGYXvMc5.js} +1 -1
- package/src/assets/web-panel/assets/{index-B0S8NzO6.js → index-BJCCa0s_.js} +1 -1
- package/src/assets/web-panel/assets/{index-DJyN28Et.js → index-BJJ5DhKI.js} +1 -1
- package/src/assets/web-panel/assets/{index-DC_OI9H2.js → index-BLOzViJ6.js} +1 -1
- package/src/assets/web-panel/assets/{index-C_XuRgGn.js → index-BRrbUMoG.js} +1 -1
- package/src/assets/web-panel/assets/{index-wvb_FtaV.js → index-BUFpT17Q.js} +1 -1
- package/src/assets/web-panel/assets/{index-KMqhcF7l.js → index-BkRDiqGc.js} +1 -1
- package/src/assets/web-panel/assets/{index-vG4XSJ1d.js → index-BsfvW6my.js} +1 -1
- package/src/assets/web-panel/assets/{index-DYXHFr4r.js → index-By-kfPED.js} +1 -1
- package/src/assets/web-panel/assets/{index-DuGszaWY.js → index-BzFTcQsc.js} +1 -1
- package/src/assets/web-panel/assets/{index-CXkJH-lK.js → index-C2FXdboq.js} +1 -1
- package/src/assets/web-panel/assets/{index-Dt5KMjwJ.js → index-C5XOlTHN.js} +1 -1
- package/src/assets/web-panel/assets/{index-4wcgvPoF.js → index-CLhBDS2w.js} +1 -1
- package/src/assets/web-panel/assets/{index-C_H_S-DY.js → index-CMq7UeoT.js} +1 -1
- package/src/assets/web-panel/assets/{index-BydjdZnl.js → index-CVMTWNKl.js} +1 -1
- package/src/assets/web-panel/assets/{index-BjHBpRYZ.js → index-Caghi-yL.js} +1 -1
- package/src/assets/web-panel/assets/{index-Bzr0rB5T.js → index-CdB6FuSH.js} +1 -1
- package/src/assets/web-panel/assets/{index-BbPtzB8M.js → index-CoK8-kWQ.js} +1 -1
- package/src/assets/web-panel/assets/{index-BUt8kTIB.js → index-CsO3GUAY.js} +1 -1
- package/src/assets/web-panel/assets/{index-C_vRgTl9.js → index-CwZBa20Z.js} +1 -1
- package/src/assets/web-panel/assets/{index-DukDVXCv.js → index-D-kASucw.js} +1 -1
- package/src/assets/web-panel/assets/{index-BbepULSc.js → index-D8eBw7vW.js} +1 -1
- package/src/assets/web-panel/assets/{index-BfuKP4qe.js → index-D9wgifk4.js} +1 -1
- package/src/assets/web-panel/assets/{index-BiC6dQZG.js → index-DAYCTc07.js} +1 -1
- package/src/assets/web-panel/assets/{index-CmFNvNLZ.js → index-DF6Q8l2h.js} +1 -1
- package/src/assets/web-panel/assets/index-DTdFUWc_.js +1 -0
- package/src/assets/web-panel/assets/{index-KjgKdZe-.js → index-DgTLApS9.js} +1 -1
- package/src/assets/web-panel/assets/{index-BpFJHiTv.js → index-DgxVJ8pg.js} +1 -1
- package/src/assets/web-panel/assets/{index-Cw0Rjy87.js → index-DlJvIqoR.js} +1 -1
- package/src/assets/web-panel/assets/{index-CFYVQMvu.js → index-Dw4eHfsG.js} +1 -1
- package/src/assets/web-panel/assets/{index-CudmwNBV.js → index-Rh-ytwow.js} +3 -3
- package/src/assets/web-panel/assets/{index-DDGaOWBV.js → index-StAEt2pq.js} +1 -1
- package/src/assets/web-panel/assets/{index-D3lD5qap.js → index-bzfw951k.js} +1 -1
- package/src/assets/web-panel/assets/{index-BzG-x9p4.js → index-fSUMbMvv.js} +1 -1
- package/src/assets/web-panel/assets/{index-B_EvG_GW.js → index-tFHy5Wol.js} +1 -1
- package/src/assets/web-panel/assets/index-zQ0M_kz-.js +1 -0
- package/src/assets/web-panel/assets/{initDefaultProps-BDgd2YZ8.js → initDefaultProps-BNq7X72N.js} +1 -1
- package/src/assets/web-panel/assets/{motion-ErtrXdYe.js → motion-DOf9viTc.js} +1 -1
- package/src/assets/web-panel/assets/{move-_GPPNCgs.js → move-CRHTcW4x.js} +1 -1
- package/src/assets/web-panel/assets/{mtc-parser-DRLL-8V2.js → mtc-parser-Cyoozgm2.js} +1 -1
- package/src/assets/web-panel/assets/{omit-p86sl023.js → omit-6-TjFNru.js} +1 -1
- package/src/assets/web-panel/assets/{pickAttrs-BTu7cMLH.js → pickAttrs-DlUmO1zi.js} +1 -1
- package/src/assets/web-panel/assets/{placementArrow-BEKz6Isr.js → placementArrow-CFOL8mIR.js} +1 -1
- package/src/assets/web-panel/assets/{responsiveObserve-Ck42c9MP.js → responsiveObserve-BtaFK5zd.js} +1 -1
- package/src/assets/web-panel/assets/{slide-1CEvuPsB.js → slide-BIbc2bur.js} +1 -1
- package/src/assets/web-panel/assets/{statusUtils-BDfdx1GJ.js → statusUtils-BXX9d6-w.js} +1 -1
- package/src/assets/web-panel/assets/{styleChecker-B4K46m5w.js → styleChecker-BGv10Bpn.js} +1 -1
- package/src/assets/web-panel/assets/{useFlexGapSupport-DoIVpJNq.js → useFlexGapSupport-bKnKT8tw.js} +1 -1
- package/src/assets/web-panel/assets/{useFs-DdAWi-ln.js → useFs-FY4IL4jn.js} +1 -1
- package/src/assets/web-panel/assets/{usePersonalDataHub-iUu6k8dx.js → usePersonalDataHub-Bwl9TEgu.js} +1 -1
- package/src/assets/web-panel/assets/{vnode-DZz4R6Tw.js → vnode-BM3OHxAT.js} +1 -1
- package/src/assets/web-panel/assets/{zoom-DXlf9sA3.js → zoom-D1fbFiHB.js} +1 -1
- package/src/assets/web-panel/index.html +1 -1
- package/src/commands/agenda.js +320 -32
- package/src/commands/agent.js +105 -11
- package/src/commands/context.js +125 -14
- package/src/commands/doctor.js +85 -1
- package/src/commands/hook.js +181 -7
- package/src/commands/plugin.js +456 -1
- package/src/commands/review.js +363 -2
- package/src/commands/session.js +191 -0
- package/src/commands/team.js +19 -1
- package/src/data/changelog.json +10 -4
- package/src/gateways/ws/remote-session-protocol.js +39 -0
- package/src/gateways/ws/session-protocol.js +55 -2
- package/src/gateways/ws/ws-agent-handler.js +50 -0
- package/src/harness/jsonl-session-store.js +60 -0
- package/src/harness/worktree-isolator.js +140 -5
- package/src/lib/agent-schedule-store.js +318 -10
- package/src/lib/agent-team/team-worktree.js +37 -2
- package/src/lib/agent-tool-admission.js +125 -0
- package/src/lib/app-preview.js +360 -0
- package/src/lib/async-hook-supervisor.cjs +54 -0
- package/src/lib/capability-manifest.js +319 -0
- package/src/lib/capability-negotiation.js +23 -12
- package/src/lib/channels/channel-manager.js +54 -2
- package/src/lib/cli-context-engineering.js +9 -2
- package/src/lib/context-breakdown.js +116 -0
- package/src/lib/diagnostic-bundle.js +230 -0
- package/src/lib/doctor-checkup.js +275 -4
- package/src/lib/execution-location.js +286 -0
- package/src/lib/headless-manifest.js +13 -40
- package/src/lib/hook-event-log.cjs +193 -0
- package/src/lib/hook-stats-store.cjs +197 -0
- package/src/lib/idempotency.js +109 -0
- package/src/lib/interaction-adapter.js +34 -4
- package/src/lib/json-schema-output.js +46 -12
- package/src/lib/json-schema-validate.js +143 -3
- package/src/lib/jsonl-session-store.js +1 -0
- package/src/lib/lsp/__tests__/lsp-manager.test.js +58 -0
- package/src/lib/lsp/lsp-manager.js +56 -0
- package/src/lib/monitor-event.js +290 -0
- package/src/lib/operation-fingerprint.js +217 -0
- package/src/lib/plugin-runtime/bin.js +37 -0
- package/src/lib/plugin-runtime/capabilities.js +19 -0
- package/src/lib/plugin-runtime/capability-consent.js +203 -0
- package/src/lib/plugin-runtime/hooks.js +37 -0
- package/src/lib/plugin-runtime/lsp.js +38 -0
- package/src/lib/plugin-runtime/mcp.js +40 -0
- package/src/lib/plugin-runtime/monitors.js +37 -0
- package/src/lib/plugin-runtime/plugin-options.js +151 -0
- package/src/lib/plugin-runtime/policy.js +62 -0
- package/src/lib/plugin-runtime/scopes.js +20 -4
- package/src/lib/pr-automation-policy.js +224 -0
- package/src/lib/project-instructions.js +128 -0
- package/src/lib/remote-approval-bridge.js +66 -13
- package/src/lib/repl-goal.js +132 -0
- package/src/lib/repl-rewind.js +153 -0
- package/src/lib/review-comment-anchor.js +241 -0
- package/src/lib/review-pipeline.js +46 -7
- package/src/lib/runtime-checkup.js +221 -0
- package/src/lib/schedule-planner.js +17 -1
- package/src/lib/secret-scan.js +409 -0
- package/src/lib/session-branch.js +0 -0
- package/src/lib/settings-hook-events.cjs +13 -0
- package/src/lib/settings-loader.cjs +42 -0
- package/src/lib/side-effect-ledger-store.js +88 -0
- package/src/lib/side-effect-ledger.js +401 -0
- package/src/lib/skill-loader.js +32 -0
- package/src/lib/skill-path-scope.js +63 -0
- package/src/lib/sub-agent-context.js +110 -9
- package/src/lib/sub-agent-registry.js +19 -0
- package/src/lib/subagent-inheritance.js +112 -0
- package/src/lib/telemetry/span-recorder.js +17 -4
- package/src/lib/terminal-context.js +195 -0
- package/src/lib/turn-binding-store.js +119 -0
- package/src/lib/unattended-action-policy.js +182 -0
- package/src/lib/worktree-cleanup-safety.js +138 -0
- package/src/lib/worktree-isolator.js +8 -0
- package/src/lib/worktree-sparse.js +131 -0
- package/src/repl/agent-repl.js +280 -30
- package/src/repl/permission-tier.js +28 -0
- package/src/runtime/agent-core.js +565 -21
- package/src/runtime/coding-agent-contract-shared.cjs +60 -1
- package/src/runtime/diagnostics.js +60 -0
- package/src/runtime/headless-runner.js +393 -1
- package/src/runtime/headless-stream.js +60 -1
- package/src/assets/web-panel/assets/MobileProjects-D10VISps.js +0 -1
- package/src/assets/web-panel/assets/devWarning-DhiGrbsh.js +0 -1
- package/src/assets/web-panel/assets/index-B7a1TrU6.js +0 -1
- package/src/assets/web-panel/assets/index-C2B1KRja.js +0 -1
|
@@ -257,6 +257,64 @@ describe("crash-loop restart guard", () => {
|
|
|
257
257
|
expect(lastClient).not.toBe(quarantined);
|
|
258
258
|
await mgr.disposeAll();
|
|
259
259
|
});
|
|
260
|
+
|
|
261
|
+
describe("exponential restart backoff (opt-in)", () => {
|
|
262
|
+
it("computes exponential backoff per crash, capped, and 0 when disabled", () => {
|
|
263
|
+
const on = new LSPManager({
|
|
264
|
+
restartBackoffBaseMs: 1000,
|
|
265
|
+
restartBackoffMaxMs: 8000,
|
|
266
|
+
});
|
|
267
|
+
expect(on._restartBackoffMs(0)).toBe(0); // first start — no wait
|
|
268
|
+
expect(on._restartBackoffMs(1)).toBe(1000);
|
|
269
|
+
expect(on._restartBackoffMs(2)).toBe(2000);
|
|
270
|
+
expect(on._restartBackoffMs(3)).toBe(4000);
|
|
271
|
+
expect(on._restartBackoffMs(4)).toBe(8000);
|
|
272
|
+
expect(on._restartBackoffMs(5)).toBe(8000); // capped
|
|
273
|
+
// Disabled by default → always 0 (byte-identical immediate respawn).
|
|
274
|
+
const off = new LSPManager();
|
|
275
|
+
expect(off.restartBackoffBaseMs).toBe(0);
|
|
276
|
+
expect(off._restartBackoffMs(2)).toBe(0);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("degrades (backing off) during the cooldown, then re-spawns once it elapses", async () => {
|
|
280
|
+
let t = 1000;
|
|
281
|
+
const mgr = new LSPManager({
|
|
282
|
+
maxRestarts: 5,
|
|
283
|
+
restartWindowMs: 60000,
|
|
284
|
+
restartBackoffBaseMs: 1000,
|
|
285
|
+
restartBackoffMaxMs: 8000,
|
|
286
|
+
now: () => t,
|
|
287
|
+
});
|
|
288
|
+
await mgr.ensureFor("/proj/src/a.ts"); // spawn #1
|
|
289
|
+
const first = lastClient;
|
|
290
|
+
crash(first); // 1 crash at t=1000
|
|
291
|
+
// Same tick → within the 1st-crash backoff (1000ms) → degrade, no respawn.
|
|
292
|
+
const backingOff = await mgr.ensureFor("/proj/src/a.ts");
|
|
293
|
+
expect(backingOff.unavailable).toBe(true);
|
|
294
|
+
expect(backingOff.backoff).toBe(true);
|
|
295
|
+
expect(backingOff.quarantined).toBeUndefined();
|
|
296
|
+
expect(backingOff.retryInMs).toBe(1000);
|
|
297
|
+
expect(lastClient).toBe(first); // did NOT spawn during cooldown
|
|
298
|
+
// Advance past the cooldown → a fresh spawn is allowed.
|
|
299
|
+
t += 1000;
|
|
300
|
+
const ready = await mgr.ensureFor("/proj/src/a.ts");
|
|
301
|
+
expect(ready.unavailable).toBeUndefined();
|
|
302
|
+
expect(lastClient).not.toBe(first);
|
|
303
|
+
await mgr.disposeAll();
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("does NOT back off by default — respawns a crashed server on the next request", async () => {
|
|
307
|
+
const mgr = new LSPManager({ maxRestarts: 3, now: () => 5000 });
|
|
308
|
+
await mgr.ensureFor("/proj/src/a.ts");
|
|
309
|
+
const first = lastClient;
|
|
310
|
+
crash(first);
|
|
311
|
+
// Same tick, backoff disabled → immediate fresh spawn (prior behaviour).
|
|
312
|
+
const ready = await mgr.ensureFor("/proj/src/a.ts");
|
|
313
|
+
expect(ready.unavailable).toBeUndefined();
|
|
314
|
+
expect(lastClient).not.toBe(first);
|
|
315
|
+
await mgr.disposeAll();
|
|
316
|
+
});
|
|
317
|
+
});
|
|
260
318
|
});
|
|
261
319
|
|
|
262
320
|
describe("diagnostics", () => {
|
|
@@ -33,6 +33,14 @@ export class LSPManager {
|
|
|
33
33
|
* @param {number} [opts.restartWindowMs] sliding window for the crash count.
|
|
34
34
|
* A server that stays healthy for this long has its crash history pruned and
|
|
35
35
|
* can restart fresh. Default 30000.
|
|
36
|
+
* @param {number} [opts.restartBackoffBaseMs] exponential backoff BETWEEN
|
|
37
|
+
* restart attempts: after the Nth recent crash the (N+1)th re-spawn waits
|
|
38
|
+
* `base·2^(N-1)` (capped by `restartBackoffMaxMs`) before it is allowed,
|
|
39
|
+
* spreading out a fast startup-crash loop instead of bursting `maxRestarts`
|
|
40
|
+
* spawns back-to-back. Default 0 = OFF (byte-identical to the prior
|
|
41
|
+
* immediate-respawn behaviour); opt-in pending evaluation.
|
|
42
|
+
* @param {number} [opts.restartBackoffMaxMs] cap for the exponential backoff.
|
|
43
|
+
* Default 8000.
|
|
36
44
|
* @param {() => number} [opts.now] injected clock (deterministic crash-loop tests)
|
|
37
45
|
*/
|
|
38
46
|
constructor(opts = {}) {
|
|
@@ -65,6 +73,19 @@ export class LSPManager {
|
|
|
65
73
|
this.restartWindowMs = Number.isFinite(opts.restartWindowMs)
|
|
66
74
|
? opts.restartWindowMs
|
|
67
75
|
: 30000;
|
|
76
|
+
// Exponential backoff BETWEEN restart attempts (distinct from the quarantine
|
|
77
|
+
// cap above). Without it, a server that crashes on startup is re-spawned on
|
|
78
|
+
// the very next request — bursting `maxRestarts` spawns back-to-back in
|
|
79
|
+
// milliseconds before quarantine. When enabled, the (N+1)th spawn after N
|
|
80
|
+
// recent crashes waits `base·2^(N-1)` (capped), so a fast crash loop can't
|
|
81
|
+
// storm the process table. Default 0 = OFF → byte-identical to the prior
|
|
82
|
+
// immediate-respawn behaviour (opt-in pending evaluation).
|
|
83
|
+
this.restartBackoffBaseMs = Number.isFinite(opts.restartBackoffBaseMs)
|
|
84
|
+
? Math.max(0, opts.restartBackoffBaseMs)
|
|
85
|
+
: 0;
|
|
86
|
+
this.restartBackoffMaxMs = Number.isFinite(opts.restartBackoffMaxMs)
|
|
87
|
+
? opts.restartBackoffMaxMs
|
|
88
|
+
: 8000;
|
|
68
89
|
this._now = typeof opts.now === "function" ? opts.now : () => Date.now();
|
|
69
90
|
/** key → array of recent crash timestamps (survives entry re-spawn) */
|
|
70
91
|
this._crashLog = new Map();
|
|
@@ -80,6 +101,18 @@ export class LSPManager {
|
|
|
80
101
|
return pruned;
|
|
81
102
|
}
|
|
82
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Minimum delay before the next restart attempt given `crashCount` recent
|
|
106
|
+
* crashes: exponential `base·2^(crashCount-1)`, capped at `restartBackoffMaxMs`.
|
|
107
|
+
* Returns 0 when backoff is disabled (base 0) or `crashCount <= 0` (a first
|
|
108
|
+
* start). Deterministic (no jitter) so crash-loop tests stay reproducible.
|
|
109
|
+
*/
|
|
110
|
+
_restartBackoffMs(crashCount) {
|
|
111
|
+
if (this.restartBackoffBaseMs <= 0 || crashCount <= 0) return 0;
|
|
112
|
+
const exp = this.restartBackoffBaseMs * 2 ** (crashCount - 1);
|
|
113
|
+
return Math.min(exp, this.restartBackoffMaxMs);
|
|
114
|
+
}
|
|
115
|
+
|
|
83
116
|
/**
|
|
84
117
|
* Ensure a server is running for `filePath` and the file is open. Returns
|
|
85
118
|
* `{ client, uri, languageId }`, or `{ unavailable: true, reason }` when no
|
|
@@ -125,6 +158,29 @@ export class LSPManager {
|
|
|
125
158
|
quarantined: true,
|
|
126
159
|
};
|
|
127
160
|
}
|
|
161
|
+
// Under the quarantine cap but recently crashed: when backoff is enabled,
|
|
162
|
+
// space out the re-spawn exponentially so a fast startup-crash loop doesn't
|
|
163
|
+
// storm the process table. The caller degrades to text search until the
|
|
164
|
+
// cooldown elapses — a transient state, distinct from quarantine.
|
|
165
|
+
if (crashes.length > 0) {
|
|
166
|
+
const backoffMs = this._restartBackoffMs(crashes.length);
|
|
167
|
+
if (backoffMs > 0) {
|
|
168
|
+
const lastCrash = crashes[crashes.length - 1];
|
|
169
|
+
const sinceLast = this._now() - lastCrash;
|
|
170
|
+
if (sinceLast < backoffMs) {
|
|
171
|
+
const retryInMs = backoffMs - sinceLast;
|
|
172
|
+
return {
|
|
173
|
+
unavailable: true,
|
|
174
|
+
reason:
|
|
175
|
+
`${resolved.id} restarting after a crash — backing off ` +
|
|
176
|
+
`~${Math.max(0, Math.round(retryInMs / 1000))}s ` +
|
|
177
|
+
`(attempt ${crashes.length + 1}/${this.maxRestarts})`,
|
|
178
|
+
backoff: true,
|
|
179
|
+
retryInMs,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
128
184
|
entry = await this._getOrStartServer(key, resolved, projectRoot);
|
|
129
185
|
}
|
|
130
186
|
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Monitor event envelope + delivery guards (P1 daemon Event Runtime —
|
|
3
|
+
* CLAUDE_CODE_CLI_INCREMENTAL_GAP_ANALYSIS_2026-07-12 §"守护进程事件运行时闭环").
|
|
4
|
+
*
|
|
5
|
+
* A Monitor watches a source (command stdout, file change, URL body, …) and
|
|
6
|
+
* "fires" when its condition matches. Before that firing reaches the agent as a
|
|
7
|
+
* turn it needs the same delivery guarantees Claude Code gives scheduled/event
|
|
8
|
+
* tasks:
|
|
9
|
+
*
|
|
10
|
+
* - **event_id** — a *deterministic* identity (no RNG) derived from the
|
|
11
|
+
* monitor id + the observation, so the same observation across a daemon
|
|
12
|
+
* restart hashes to the same id and can be de-duplicated.
|
|
13
|
+
* - **dedup window** — a time+count bounded set of recently-seen ids so a
|
|
14
|
+
* monitor that matches every poll does not re-fire the identical event.
|
|
15
|
+
* - **size cap** — oversized payloads are truncated (with a flag) so a
|
|
16
|
+
* runaway command output cannot blow up a turn / the audit log.
|
|
17
|
+
* - **backpressure** — a bounded queue that drops (and counts) events rather
|
|
18
|
+
* than growing without limit when the agent is slower than the source.
|
|
19
|
+
* - **authority** — a monitor event is an internal SYSTEM event: it tops out
|
|
20
|
+
* at `steer` (agent-authority.js) and can NEVER answer a permission gate.
|
|
21
|
+
*
|
|
22
|
+
* Pure + dependency-light (node:crypto + agent-authority.js) so any monitor
|
|
23
|
+
* firing seam — daemon runtime, `cc agenda run`, a future in-process runtime —
|
|
24
|
+
* can share exactly one implementation of these invariants.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { createHash } from "node:crypto";
|
|
28
|
+
import {
|
|
29
|
+
ORIGIN,
|
|
30
|
+
authorityForOrigin,
|
|
31
|
+
canApprove,
|
|
32
|
+
describeAuthorityChain,
|
|
33
|
+
} from "./agent-authority.js";
|
|
34
|
+
|
|
35
|
+
/** Default per-event payload cap (64 KiB) — matches the schedule store's audit budget. */
|
|
36
|
+
export const DEFAULT_MAX_EVENT_BYTES = 64 * 1024;
|
|
37
|
+
|
|
38
|
+
/** Default dedup window: an id seen within 5 minutes (or the last 1000 ids) is a repeat. */
|
|
39
|
+
export const DEFAULT_DEDUP_WINDOW_MS = 5 * 60 * 1000;
|
|
40
|
+
export const DEFAULT_DEDUP_MAX_ENTRIES = 1000;
|
|
41
|
+
|
|
42
|
+
/** Default bounded queue depth before backpressure drops kick in. */
|
|
43
|
+
export const DEFAULT_QUEUE_MAX_SIZE = 256;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A stable string form of an observation so structurally-equal observations
|
|
47
|
+
* hash identically and any real change hashes differently. Mirrors
|
|
48
|
+
* agent-authority's `normalizeToolArgs` intent (key-order independent).
|
|
49
|
+
*/
|
|
50
|
+
function stablePayload(payload) {
|
|
51
|
+
return stableStringify(payload === undefined ? null : payload);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function stableStringify(value) {
|
|
55
|
+
if (value === null || typeof value !== "object") {
|
|
56
|
+
return JSON.stringify(value === undefined ? null : value);
|
|
57
|
+
}
|
|
58
|
+
if (Array.isArray(value)) {
|
|
59
|
+
return "[" + value.map(stableStringify).join(",") + "]";
|
|
60
|
+
}
|
|
61
|
+
const keys = Object.keys(value).sort();
|
|
62
|
+
return (
|
|
63
|
+
"{" +
|
|
64
|
+
keys
|
|
65
|
+
.map((k) => JSON.stringify(k) + ":" + stableStringify(value[k]))
|
|
66
|
+
.join(",") +
|
|
67
|
+
"}"
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Deterministic event id for a monitor observation. Same `(monitorId, payload)`
|
|
73
|
+
* → same id, forever, with no RNG — so a daemon restart re-observing the same
|
|
74
|
+
* thing is recognised as a repeat instead of re-firing.
|
|
75
|
+
*/
|
|
76
|
+
export function monitorEventId(monitorId, payload) {
|
|
77
|
+
const h = createHash("sha256");
|
|
78
|
+
h.update("cc-monitor-event-v1\n");
|
|
79
|
+
h.update(String(monitorId ?? "") + "\n");
|
|
80
|
+
h.update(stablePayload(payload) + "\n");
|
|
81
|
+
return "ev_" + h.digest("hex").slice(0, 24);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Cap a payload's serialized size. Returns `{ value, truncated, bytes }`.
|
|
86
|
+
* Strings are truncated by UTF-8 byte length; non-strings are JSON-serialized,
|
|
87
|
+
* and if the JSON is over budget the *serialized string* is truncated and
|
|
88
|
+
* returned (so the event still carries a bounded, human-readable snippet rather
|
|
89
|
+
* than an unbounded object).
|
|
90
|
+
*/
|
|
91
|
+
export function capEventPayload(payload, maxBytes = DEFAULT_MAX_EVENT_BYTES) {
|
|
92
|
+
const limit = Math.max(0, Number(maxBytes) || 0);
|
|
93
|
+
const asString =
|
|
94
|
+
typeof payload === "string" ? payload : safeJsonStringify(payload);
|
|
95
|
+
const bytes = Buffer.byteLength(asString, "utf8");
|
|
96
|
+
if (limit === 0 || bytes <= limit) {
|
|
97
|
+
return { value: payload, truncated: false, bytes };
|
|
98
|
+
}
|
|
99
|
+
const truncated = truncateUtf8(asString, limit);
|
|
100
|
+
return {
|
|
101
|
+
value: truncated,
|
|
102
|
+
truncated: true,
|
|
103
|
+
bytes: Buffer.byteLength(truncated, "utf8"),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function safeJsonStringify(value) {
|
|
108
|
+
try {
|
|
109
|
+
return JSON.stringify(value === undefined ? null : value);
|
|
110
|
+
} catch {
|
|
111
|
+
return String(value);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Truncate a string to at most `maxBytes` UTF-8 bytes without splitting a code point. */
|
|
116
|
+
function truncateUtf8(str, maxBytes) {
|
|
117
|
+
const buf = Buffer.from(str, "utf8");
|
|
118
|
+
if (buf.length <= maxBytes) return str;
|
|
119
|
+
let end = maxBytes;
|
|
120
|
+
// Back off if we would land in the middle of a multi-byte sequence.
|
|
121
|
+
while (end > 0 && (buf[end] & 0xc0) === 0x80) end--;
|
|
122
|
+
return buf.toString("utf8", 0, end);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The unforgeable authority envelope for a monitor firing. A monitor event is
|
|
127
|
+
* an internal SYSTEM observation: it may add a turn (`steer`) but can NEVER
|
|
128
|
+
* answer a permission gate — matching the channel/hook ceiling. `origin` is set
|
|
129
|
+
* here by WHAT produced the event (a monitor), never read from its content.
|
|
130
|
+
*
|
|
131
|
+
* Returns `{ event_id, monitorId, origin, authority, canApprove, provenance,
|
|
132
|
+
* at, truncated?, source? }` — a caller merges its own observation fields in.
|
|
133
|
+
*/
|
|
134
|
+
export function monitorEventEnvelope(
|
|
135
|
+
monitorId,
|
|
136
|
+
payload,
|
|
137
|
+
{ at = null, source = null, maxBytes = DEFAULT_MAX_EVENT_BYTES } = {},
|
|
138
|
+
) {
|
|
139
|
+
const capped = capEventPayload(payload, maxBytes);
|
|
140
|
+
const envelope = {
|
|
141
|
+
origin: ORIGIN.SYSTEM,
|
|
142
|
+
principalId: monitorId != null ? String(monitorId) : null,
|
|
143
|
+
correlationId: source != null ? String(source) : null,
|
|
144
|
+
};
|
|
145
|
+
return {
|
|
146
|
+
event_id: monitorEventId(monitorId, payload),
|
|
147
|
+
monitorId: monitorId != null ? String(monitorId) : null,
|
|
148
|
+
source: source != null ? String(source) : null,
|
|
149
|
+
at: at != null ? Number(at) : null,
|
|
150
|
+
payload: capped.value,
|
|
151
|
+
truncated: capped.truncated,
|
|
152
|
+
origin: envelope.origin,
|
|
153
|
+
authority: authorityForOrigin(ORIGIN.SYSTEM, envelope), // always "steer"
|
|
154
|
+
canApprove: canApprove(envelope), // always false
|
|
155
|
+
provenance: describeAuthorityChain(envelope),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* A time + count bounded set of recently-seen event ids. `shouldEmit(id, now)`
|
|
161
|
+
* records the id and returns `true` only when it is NOT a repeat within the
|
|
162
|
+
* window — so a monitor matching every poll fires the identical event once.
|
|
163
|
+
* Eviction is by age (older than `windowMs`) and by count (`maxEntries`, oldest
|
|
164
|
+
* first), both bounded so memory never grows without limit.
|
|
165
|
+
*/
|
|
166
|
+
export class DedupWindow {
|
|
167
|
+
constructor({
|
|
168
|
+
windowMs = DEFAULT_DEDUP_WINDOW_MS,
|
|
169
|
+
maxEntries = DEFAULT_DEDUP_MAX_ENTRIES,
|
|
170
|
+
} = {}) {
|
|
171
|
+
this._windowMs = Math.max(0, Number(windowMs) || 0);
|
|
172
|
+
this._maxEntries = Math.max(1, Number(maxEntries) || 1);
|
|
173
|
+
// Insertion-ordered id → lastSeen epoch-ms (Map preserves insertion order).
|
|
174
|
+
this._seen = new Map();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Drop entries older than the window relative to `now`. A full sweep (no
|
|
179
|
+
* early break): refreshing an id updates its timestamp but keeps its Map
|
|
180
|
+
* position, so timestamps are NOT monotonic in iteration order and an early
|
|
181
|
+
* break could skip a genuinely-expired later entry — which would then wrongly
|
|
182
|
+
* suppress its legitimate re-emit. The map is bounded by `maxEntries`, so a
|
|
183
|
+
* full pass is cheap and strictly correct.
|
|
184
|
+
*/
|
|
185
|
+
_evictExpired(now) {
|
|
186
|
+
if (this._windowMs === 0) return;
|
|
187
|
+
const cutoff = now - this._windowMs;
|
|
188
|
+
for (const [id, ts] of this._seen) {
|
|
189
|
+
if (ts <= cutoff) this._seen.delete(id);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Enforce the count cap by evicting the oldest entries. */
|
|
194
|
+
_evictOverflow() {
|
|
195
|
+
while (this._seen.size > this._maxEntries) {
|
|
196
|
+
const oldest = this._seen.keys().next().value;
|
|
197
|
+
this._seen.delete(oldest);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* True if `eventId` is new within the window (and records it); false if it is
|
|
203
|
+
* a repeat (refreshes its timestamp so a steady stream keeps it suppressed).
|
|
204
|
+
*/
|
|
205
|
+
shouldEmit(eventId, now = 0) {
|
|
206
|
+
const nowMs = Number(now) || 0;
|
|
207
|
+
this._evictExpired(nowMs);
|
|
208
|
+
if (this._seen.has(eventId)) {
|
|
209
|
+
// Refresh recency without changing insertion order semantics for eviction.
|
|
210
|
+
this._seen.set(eventId, nowMs);
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
this._seen.set(eventId, nowMs);
|
|
214
|
+
this._evictOverflow();
|
|
215
|
+
return true;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Number of live (un-evicted) ids currently tracked. */
|
|
219
|
+
get size() {
|
|
220
|
+
return this._seen.size;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Forget everything (e.g. on monitor removal). */
|
|
224
|
+
clear() {
|
|
225
|
+
this._seen.clear();
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* A bounded event queue providing backpressure: once full, a `push` drops an
|
|
231
|
+
* event rather than growing the queue, and counts what it dropped so the loss
|
|
232
|
+
* is observable (and auditable) instead of silent.
|
|
233
|
+
*
|
|
234
|
+
* - `dropPolicy: "oldest"` (default) — evict the head to admit the newcomer
|
|
235
|
+
* (keep the freshest signal; good for state monitors).
|
|
236
|
+
* - `dropPolicy: "newest"` — reject the newcomer (preserve order; good when
|
|
237
|
+
* the first observations matter most).
|
|
238
|
+
*/
|
|
239
|
+
export class BoundedEventQueue {
|
|
240
|
+
constructor({
|
|
241
|
+
maxSize = DEFAULT_QUEUE_MAX_SIZE,
|
|
242
|
+
dropPolicy = "oldest",
|
|
243
|
+
} = {}) {
|
|
244
|
+
this._maxSize = Math.max(1, Number(maxSize) || 1);
|
|
245
|
+
this._dropPolicy = dropPolicy === "newest" ? "newest" : "oldest";
|
|
246
|
+
this._items = [];
|
|
247
|
+
this._dropped = 0;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Push an event. Returns `{ accepted, dropped }` where `dropped` is the event
|
|
252
|
+
* evicted/rejected to stay within bounds (or null when nothing was dropped).
|
|
253
|
+
*/
|
|
254
|
+
push(event) {
|
|
255
|
+
if (this._items.length < this._maxSize) {
|
|
256
|
+
this._items.push(event);
|
|
257
|
+
return { accepted: true, dropped: null };
|
|
258
|
+
}
|
|
259
|
+
if (this._dropPolicy === "newest") {
|
|
260
|
+
this._dropped++;
|
|
261
|
+
return { accepted: false, dropped: event };
|
|
262
|
+
}
|
|
263
|
+
// oldest: evict head, admit newcomer.
|
|
264
|
+
const dropped = this._items.shift();
|
|
265
|
+
this._items.push(event);
|
|
266
|
+
this._dropped++;
|
|
267
|
+
return { accepted: true, dropped };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Remove and return the next event, or undefined when empty. */
|
|
271
|
+
shift() {
|
|
272
|
+
return this._items.shift();
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Remove and return every queued event, oldest first. */
|
|
276
|
+
drain() {
|
|
277
|
+
const out = this._items;
|
|
278
|
+
this._items = [];
|
|
279
|
+
return out;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
get size() {
|
|
283
|
+
return this._items.length;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Total events dropped due to backpressure since construction. */
|
|
287
|
+
get dropped() {
|
|
288
|
+
return this._dropped;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
@@ -93,3 +93,220 @@ export function approvalFingerprintOk(pending, resolved) {
|
|
|
93
93
|
if (!pending) return true; // nothing to bind against (legacy pending ask)
|
|
94
94
|
return fingerprintsMatch(pending, resolved);
|
|
95
95
|
}
|
|
96
|
+
|
|
97
|
+
// ── §8.2 cross-device operation fingerprint ──────────────────────────────────
|
|
98
|
+
//
|
|
99
|
+
// The fingerprint above binds tool + params only. IDE gap §8.2 requires a
|
|
100
|
+
// CROSS-DEVICE identity that also covers target environment, workspace, session,
|
|
101
|
+
// policy VERSION and a validity window, plus a fail-closed resolver: offline
|
|
102
|
+
// replay, multi-card concurrency, timeout and duplicate submission must all be
|
|
103
|
+
// rejected. The functions below add that richer tuple WITHOUT changing the
|
|
104
|
+
// legacy fingerprint (existing devices/tests keep working).
|
|
105
|
+
//
|
|
106
|
+
// Two layers of identity:
|
|
107
|
+
// - the LOGICAL key = tool + params + env + workspace + session + policyVersion
|
|
108
|
+
// (what the human is approving). Any change here invalidates an old approval.
|
|
109
|
+
// - the FINGERPRINT = the logical key PLUS the validity window, so a card
|
|
110
|
+
// re-issued with a fresh window is a distinct fingerprint (an old card can
|
|
111
|
+
// never be replayed) while still mapping to the same logical operation.
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The logical-operation key preimage: everything the human is authorizing,
|
|
115
|
+
* EXCLUDING the validity window. Two asks with this equal are the same
|
|
116
|
+
* operation (so re-issuing one supersedes the other). Deterministic.
|
|
117
|
+
* @param {object} desc
|
|
118
|
+
*/
|
|
119
|
+
export function operationDescriptorKey(desc = {}) {
|
|
120
|
+
return canonicalize({
|
|
121
|
+
tool: desc.toolName ?? desc.tool ?? null,
|
|
122
|
+
params: desc.params !== undefined ? desc.params : (desc.detail ?? null),
|
|
123
|
+
targetEnv: desc.targetEnv ?? null,
|
|
124
|
+
workspace: desc.workspace ?? null,
|
|
125
|
+
session: desc.session ?? null,
|
|
126
|
+
policyVersion: desc.policyVersion ?? null,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function intOrNull(value) {
|
|
131
|
+
const n = Number(value);
|
|
132
|
+
return Number.isFinite(n) ? n : null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The full §8.2 cross-device fingerprint: the logical key plus the validity
|
|
137
|
+
* window `[notBefore, notAfter]`. Prefixed `opf_` (40 hex) so it is never
|
|
138
|
+
* confused with the 32-hex legacy fingerprint in a log or on the wire.
|
|
139
|
+
*
|
|
140
|
+
* @param {object} desc
|
|
141
|
+
* @param {string} [desc.toolName]
|
|
142
|
+
* @param {*} [desc.params]
|
|
143
|
+
* @param {string} [desc.targetEnv] e.g. local / wsl / ssh:host / container
|
|
144
|
+
* @param {string} [desc.workspace]
|
|
145
|
+
* @param {string} [desc.session]
|
|
146
|
+
* @param {string} [desc.policyVersion] a REAL policy version, not a rule label
|
|
147
|
+
* @param {number} [desc.notBefore] ms epoch the approval becomes valid
|
|
148
|
+
* @param {number} [desc.notAfter] ms epoch the approval expires
|
|
149
|
+
* @returns {string} `opf_` + 40 lowercase hex
|
|
150
|
+
*/
|
|
151
|
+
export function computeOperationFingerprint(desc = {}) {
|
|
152
|
+
const preimage =
|
|
153
|
+
"cc-operation-fingerprint-v2\n" +
|
|
154
|
+
operationDescriptorKey(desc) +
|
|
155
|
+
"\n" +
|
|
156
|
+
canonicalize({
|
|
157
|
+
notBefore: intOrNull(desc.notBefore),
|
|
158
|
+
notAfter: intOrNull(desc.notAfter),
|
|
159
|
+
});
|
|
160
|
+
return (
|
|
161
|
+
"opf_" +
|
|
162
|
+
createHash("sha256").update(preimage, "utf8").digest("hex").slice(0, 40)
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* A short, human-verifiable id for the approval card — the operator eyeballs it
|
|
168
|
+
* on the device against the terminal to confirm they answer the SAME card. Two
|
|
169
|
+
* groups of 4 uppercase hex from the fingerprint (never the secret params).
|
|
170
|
+
* @param {string} fingerprint
|
|
171
|
+
* @returns {string} e.g. "A1B2-C3D4"
|
|
172
|
+
*/
|
|
173
|
+
export function shortOperationId(fingerprint) {
|
|
174
|
+
const hex = String(fingerprint || "").replace(/^opf_/, "");
|
|
175
|
+
const s = (hex.slice(0, 8) || "00000000").toUpperCase();
|
|
176
|
+
return `${s.slice(0, 4)}-${s.slice(4, 8)}`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* A readable, SECRET-FREE one-line summary for the approval card. It NEVER
|
|
181
|
+
* echoes param VALUES (a shell command / token could be secret) — only the
|
|
182
|
+
* tool, the param key names (or an opaque marker), and the target coordinates.
|
|
183
|
+
* @param {object} desc
|
|
184
|
+
* @returns {string}
|
|
185
|
+
*/
|
|
186
|
+
export function summarizeOperation(desc = {}) {
|
|
187
|
+
const tool = String(desc.toolName ?? desc.tool ?? "operation");
|
|
188
|
+
let paramHint = "";
|
|
189
|
+
const p = desc.params;
|
|
190
|
+
if (p && typeof p === "object" && !Array.isArray(p)) {
|
|
191
|
+
const keys = Object.keys(p).sort();
|
|
192
|
+
paramHint = keys.length ? `(${keys.join(", ")})` : "()";
|
|
193
|
+
} else if (p !== undefined && p !== null && p !== "") {
|
|
194
|
+
paramHint = "(…)"; // opaque scalar/string/array — do not reveal the value
|
|
195
|
+
}
|
|
196
|
+
const coords = [];
|
|
197
|
+
if (desc.workspace) coords.push(`ws:${desc.workspace}`);
|
|
198
|
+
if (desc.targetEnv) coords.push(`env:${desc.targetEnv}`);
|
|
199
|
+
if (desc.session) coords.push(`sess:${desc.session}`);
|
|
200
|
+
if (desc.policyVersion) coords.push(`pol:${desc.policyVersion}`);
|
|
201
|
+
return `${tool}${paramHint}${coords.length ? " · " + coords.join(" · ") : ""}`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Fail-closed verdict for resolving one pending approval. Every rejection is
|
|
206
|
+
* named so the audit log records WHY. Ordering matters: a missing input or a
|
|
207
|
+
* superseded/duplicate card is rejected before the fingerprint compare so a
|
|
208
|
+
* stale card can never settle a gate.
|
|
209
|
+
*
|
|
210
|
+
* @param {object} pending {fingerprint, notBefore?, notAfter?, resolved?, supersededBy?}
|
|
211
|
+
* @param {object} resolution {fingerprint}
|
|
212
|
+
* @param {object} [opts]
|
|
213
|
+
* @param {number} [opts.now] ms epoch (injected clock; required for window checks)
|
|
214
|
+
* @returns {{ok:boolean, reason:(string|null)}}
|
|
215
|
+
*/
|
|
216
|
+
export function resolveOperationApproval(pending, resolution, opts = {}) {
|
|
217
|
+
if (!pending || typeof pending !== "object")
|
|
218
|
+
return { ok: false, reason: "no-pending" };
|
|
219
|
+
if (!resolution || typeof resolution !== "object")
|
|
220
|
+
return { ok: false, reason: "no-resolution" };
|
|
221
|
+
if (pending.resolved) return { ok: false, reason: "duplicate" };
|
|
222
|
+
if (pending.supersededBy != null) return { ok: false, reason: "superseded" };
|
|
223
|
+
|
|
224
|
+
if (!fingerprintsMatch(pending.fingerprint, resolution.fingerprint)) {
|
|
225
|
+
return { ok: false, reason: "fingerprint-mismatch" };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const now = intOrNull(opts.now);
|
|
229
|
+
const nb = intOrNull(pending.notBefore);
|
|
230
|
+
const na = intOrNull(pending.notAfter);
|
|
231
|
+
if (nb != null || na != null) {
|
|
232
|
+
if (now == null) return { ok: false, reason: "no-clock" }; // can't prove validity → closed
|
|
233
|
+
if (nb != null && now < nb) return { ok: false, reason: "not-yet-valid" };
|
|
234
|
+
if (na != null && now > na) return { ok: false, reason: "expired" };
|
|
235
|
+
}
|
|
236
|
+
return { ok: true, reason: null };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* In-memory registry that enforces the §8.2 cross-device guarantees for a set
|
|
241
|
+
* of live approval cards: single-winner across concurrent cards for one logical
|
|
242
|
+
* operation (multi-card), at-most-once (duplicate submission), and validity
|
|
243
|
+
* window (timeout / not-yet-valid). Pure aside from an injected `clock` — no
|
|
244
|
+
* fs/RNG — so it is fully unit-testable and serializable alongside a session.
|
|
245
|
+
*/
|
|
246
|
+
export class OperationApprovalRegistry {
|
|
247
|
+
constructor({ clock } = {}) {
|
|
248
|
+
this._byFingerprint = new Map(); // fingerprint -> pending record
|
|
249
|
+
this._activeByLogicalKey = new Map(); // logical key -> active fingerprint
|
|
250
|
+
this._clock = typeof clock === "function" ? clock : null;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
_now() {
|
|
254
|
+
return this._clock ? intOrNull(this._clock()) : null;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Issue a card for an operation. Any still-unresolved card for the SAME
|
|
259
|
+
* logical operation is superseded (single-winner) so only this newest card
|
|
260
|
+
* can resolve.
|
|
261
|
+
* @returns {{fingerprint:string, shortId:string, summary:string}}
|
|
262
|
+
*/
|
|
263
|
+
issue(desc = {}) {
|
|
264
|
+
const fingerprint = computeOperationFingerprint(desc);
|
|
265
|
+
const logicalKey = operationDescriptorKey(desc);
|
|
266
|
+
|
|
267
|
+
const priorFp = this._activeByLogicalKey.get(logicalKey);
|
|
268
|
+
if (priorFp && priorFp !== fingerprint) {
|
|
269
|
+
const prior = this._byFingerprint.get(priorFp);
|
|
270
|
+
if (prior && !prior.resolved) prior.supersededBy = fingerprint;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
this._byFingerprint.set(fingerprint, {
|
|
274
|
+
fingerprint,
|
|
275
|
+
logicalKey,
|
|
276
|
+
notBefore: intOrNull(desc.notBefore),
|
|
277
|
+
notAfter: intOrNull(desc.notAfter),
|
|
278
|
+
resolved: false,
|
|
279
|
+
supersededBy: null,
|
|
280
|
+
});
|
|
281
|
+
this._activeByLogicalKey.set(logicalKey, fingerprint);
|
|
282
|
+
|
|
283
|
+
return {
|
|
284
|
+
fingerprint,
|
|
285
|
+
shortId: shortOperationId(fingerprint),
|
|
286
|
+
summary: summarizeOperation(desc),
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Resolve a card by fingerprint. Fails closed (with a named reason) on an
|
|
292
|
+
* unknown, superseded, duplicate, mismatched, not-yet-valid or expired card.
|
|
293
|
+
* On success the card is marked resolved so a replay is a `duplicate`.
|
|
294
|
+
* @param {string} fingerprint
|
|
295
|
+
* @param {object} [opts] {now?} overrides the injected clock
|
|
296
|
+
* @returns {{ok:boolean, reason:(string|null)}}
|
|
297
|
+
*/
|
|
298
|
+
resolve(fingerprint, opts = {}) {
|
|
299
|
+
const pending = this._byFingerprint.get(fingerprint);
|
|
300
|
+
if (!pending) return { ok: false, reason: "unknown" };
|
|
301
|
+
const now = opts.now !== undefined ? intOrNull(opts.now) : this._now();
|
|
302
|
+
const verdict = resolveOperationApproval(pending, { fingerprint }, { now });
|
|
303
|
+
if (verdict.ok) pending.resolved = true;
|
|
304
|
+
return verdict;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** A read-only view of a pending card (or null). */
|
|
308
|
+
get(fingerprint) {
|
|
309
|
+
const p = this._byFingerprint.get(fingerprint);
|
|
310
|
+
return p ? { ...p } : null;
|
|
311
|
+
}
|
|
312
|
+
}
|