chainlesschain 0.162.166 → 0.162.168
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 +1 -1
- package/src/assets/web-panel/assets/{AIOps-LbDx7jL6.js → AIOps-CDzzuWZV.js} +1 -1
- package/src/assets/web-panel/assets/{ActionButton-DnurtK34.js → ActionButton-Dy-8MmQi.js} +1 -1
- package/src/assets/web-panel/assets/{Analytics-DXSsV1am.js → Analytics-BtJ_VWkx.js} +3 -3
- package/src/assets/web-panel/assets/{AppLayout-dqRpzNuD.js → AppLayout-DkjiI4VS.js} +5 -5
- package/src/assets/web-panel/assets/{Artifacts-BwdmYepK.js → Artifacts-h8PyDO86.js} +1 -1
- package/src/assets/web-panel/assets/{Audit-oSX0kOUG.js → Audit-DiDV0C6l.js} +1 -1
- package/src/assets/web-panel/assets/{BackgroundAgents-C2KxJahT.js → BackgroundAgents-DTw_aOaf.js} +1 -1
- package/src/assets/web-panel/assets/{Backup-B4TkxWkC.js → Backup-CihFuUO9.js} +1 -1
- package/src/assets/web-panel/assets/{BaseInput-CX5-KxeU.js → BaseInput-CqP2QNoR.js} +1 -1
- package/src/assets/web-panel/assets/{Chat-O_HzD-PL.js → Chat-tm-ygX-p.js} +5 -5
- package/src/assets/web-panel/assets/{ChatBubbleRenderer-Br8saHQA.js → ChatBubbleRenderer-BVKEhj_y.js} +1 -1
- package/src/assets/web-panel/assets/{Checkbox-ChepDpC1.js → Checkbox-BFMyRN3Q.js} +1 -1
- package/src/assets/web-panel/assets/{Codegen-D9z8kc7q.js → Codegen-B1gTeoAS.js} +1 -1
- package/src/assets/web-panel/assets/{Col-B-H5eoNw.js → Col-1GP8MvKL.js} +1 -1
- package/src/assets/web-panel/assets/{Community-Ccyp_Kk5.js → Community-BLY938F6.js} +1 -1
- package/src/assets/web-panel/assets/{Compact-BNvBznp_.js → Compact-DVt_p36I.js} +1 -1
- package/src/assets/web-panel/assets/{Compliance-D1FjRd8n.js → Compliance-DxqEBXVG.js} +1 -1
- package/src/assets/web-panel/assets/{Cowork-CV9B9u-8.js → Cowork-CyWrM-T8.js} +3 -3
- package/src/assets/web-panel/assets/{Cron-BowJ4VIx.js → Cron-Domu6aI9.js} +2 -2
- package/src/assets/web-panel/assets/{Crosschain-BJc6jzRF.js → Crosschain-C8LKpjHJ.js} +1 -1
- package/src/assets/web-panel/assets/{DID-BEp8NV6D.js → DID-XBy15RZ5.js} +2 -2
- package/src/assets/web-panel/assets/{Dashboard-Dls0B3YB.js → Dashboard-DDAyPD-V.js} +2 -2
- package/src/assets/web-panel/assets/{Dropdown-DQ7zXVtD.js → Dropdown-BHV0MZ13.js} +1 -1
- package/src/assets/web-panel/assets/{EmailListRenderer-B9q79tOl.js → EmailListRenderer-DmFGW4PX.js} +1 -1
- package/src/assets/web-panel/assets/{FamilyGuardDashboard-CD8Ie-88.js → FamilyGuardDashboard-CqYdbKvd.js} +1 -1
- package/src/assets/web-panel/assets/{Federation-5o7XU5Vf.js → Federation-YP5-Puv5.js} +1 -1
- package/src/assets/web-panel/assets/{FormItemContext-DeKbMvOs.js → FormItemContext-BOmcIcTe.js} +1 -1
- package/src/assets/web-panel/assets/{GenericCardRenderer-CZiQvz_z.js → GenericCardRenderer-CvTZdoc4.js} +1 -1
- package/src/assets/web-panel/assets/{Git-CTvFxVV8.js → Git-Q2oxua0L.js} +2 -2
- package/src/assets/web-panel/assets/{Governance-DwzFIjHt.js → Governance-D-WpyWEU.js} +1 -1
- package/src/assets/web-panel/assets/{Inference-CJBk59c-.js → Inference-NUtsYHQo.js} +1 -1
- package/src/assets/web-panel/assets/{KnowledgeGraph-DfsaXN2z.js → KnowledgeGraph-BHQLa8fr.js} +1 -1
- package/src/assets/web-panel/assets/{Logs-DVRYf18S.js → Logs-6WsR5Q1G.js} +2 -2
- package/src/assets/web-panel/assets/MarkdownRenderer-DuWoDwMx.js +1 -0
- package/src/assets/web-panel/assets/{Marketplace-BuIwyE4t.js → Marketplace-CigohRRW.js} +1 -1
- package/src/assets/web-panel/assets/{McpTools-D3U0o78E.js → McpTools-jcp6jVLf.js} +5 -5
- package/src/assets/web-panel/assets/{Memory-QYVmyb3b.js → Memory-B00NU1ph.js} +2 -2
- package/src/assets/web-panel/assets/{MobileBridge-BFgrj6eZ.js → MobileBridge-Cs_p3PzU.js} +1 -1
- package/src/assets/web-panel/assets/MobileProjects-BFs2WljZ.js +1 -0
- package/src/assets/web-panel/assets/{Mtc-BZQu8TcH.js → Mtc-CSEflwzw.js} +5 -5
- package/src/assets/web-panel/assets/{MtcAudit-BnFfEkLo.js → MtcAudit-dxSiEZ96.js} +2 -2
- package/src/assets/web-panel/assets/{Multisig-iUnNS9Cm.js → Multisig-CDAFEAyq.js} +3 -3
- package/src/assets/web-panel/assets/{NLProgramming-DNNz1y9Y.js → NLProgramming-HoFpZNiB.js} +1 -1
- package/src/assets/web-panel/assets/{Notes-CZezNYRU.js → Notes-tAoQvVcE.js} +3 -3
- package/src/assets/web-panel/assets/{NotificationSettings-DBvrplIR.js → NotificationSettings-Czo2M9F4.js} +1 -1
- package/src/assets/web-panel/assets/{OrderTableRenderer-CTDtee49.js → OrderTableRenderer-CM1kNTp6.js} +1 -1
- package/src/assets/web-panel/assets/{Organization-CCOUJX1R.js → Organization-Cudr0VOC.js} +4 -4
- package/src/assets/web-panel/assets/{Overflow-DcZghdk0.js → Overflow-BSXeDKiN.js} +1 -1
- package/src/assets/web-panel/assets/{P2P-jmddWX9q.js → P2P-Cqv_g-F9.js} +2 -2
- package/src/assets/web-panel/assets/{PdhVaultBrowser-wfjMcczl.js → PdhVaultBrowser-7rjprPh8.js} +4 -4
- package/src/assets/web-panel/assets/{Permissions-DU1GJjca.js → Permissions-97pIDQKE.js} +3 -3
- package/src/assets/web-panel/assets/{PersonalDataHub-BzTMzDF9.js → PersonalDataHub-bparK9eQ.js} +3 -3
- package/src/assets/web-panel/assets/{Pipeline-DPmjlpVc.js → Pipeline-CJgerM09.js} +1 -1
- package/src/assets/web-panel/assets/{Privacy-B6HRLpfK.js → Privacy-JqFS3PN7.js} +1 -1
- package/src/assets/web-panel/assets/{ProjectInit-Cyy86AsN.js → ProjectInit-DMITd-2c.js} +2 -2
- package/src/assets/web-panel/assets/{ProjectSettings-Bl_WQxJ5.js → ProjectSettings-1UkdJ8Ii.js} +2 -2
- package/src/assets/web-panel/assets/{Projects-Co9yKThA.js → Projects-CYZqegqw.js} +1 -1
- package/src/assets/web-panel/assets/{Providers-DLa4NlIs.js → Providers-ChfXz3U0.js} +1 -1
- package/src/assets/web-panel/assets/{QrScannerModal-BvzM7q5f.js → QrScannerModal-Rzkka-mf.js} +1 -1
- package/src/assets/web-panel/assets/{QuickAsk-BN8yyyUj.js → QuickAsk-DEz9HaON.js} +1 -1
- package/src/assets/web-panel/assets/{Recommend-QsVN4w0i.js → Recommend-EFGP8AVu.js} +1 -1
- package/src/assets/web-panel/assets/{RemoteSession-tkDXw-TY.js → RemoteSession-6um0lGGy.js} +2 -2
- package/src/assets/web-panel/assets/{Reputation-DAifkGsy.js → Reputation-CA1BXTup.js} +1 -1
- package/src/assets/web-panel/assets/{Row-D7dZEyak.js → Row-stTnDJ9y.js} +1 -1
- package/src/assets/web-panel/assets/{RssFeed-DB5sImTA.js → RssFeed-B10qCCoG.js} +2 -2
- package/src/assets/web-panel/assets/{Search-3py3TO9V.js → Search-DSJroZ6p.js} +1 -1
- package/src/assets/web-panel/assets/{Security-DhNUfT_k.js → Security-BpXkwiMW.js} +4 -4
- package/src/assets/web-panel/assets/{Services-DjJAA_5D.js → Services-BPQxjwK6.js} +2 -2
- package/src/assets/web-panel/assets/{Skeleton-u95JjK3n.js → Skeleton-CfbKAMul.js} +1 -1
- package/src/assets/web-panel/assets/{Skills-DPo3L0Tz.js → Skills-CttYTfX0.js} +1 -1
- package/src/assets/web-panel/assets/{Sla-DWhkAZV1.js → Sla-ByluHich.js} +1 -1
- package/src/assets/web-panel/assets/{SpeechSettings-PUegMtqV.js → SpeechSettings-BdZXKsfV.js} +1 -1
- package/src/assets/web-panel/assets/{SyncSettings-BMNYrnrU.js → SyncSettings-xCR7iDzy.js} +2 -2
- package/src/assets/web-panel/assets/{Tasks-CYm_oTJC.js → Tasks-qTko_INn.js} +1 -1
- package/src/assets/web-panel/assets/{Templates-Dw9Er5DN.js → Templates-CkIzhgDJ.js} +1 -1
- package/src/assets/web-panel/assets/{Tenant-7P3QIBSo.js → Tenant-BIaCaaK8.js} +1 -1
- package/src/assets/web-panel/assets/Terminal-B2Qq38XP.js +3 -0
- package/src/assets/web-panel/assets/{TimelineRenderer-DbCd1bDH.js → TimelineRenderer-D5eQDnEF.js} +1 -1
- package/src/assets/web-panel/assets/{Tokens-EQofpA0t.js → Tokens-DmeJ8NMQ.js} +1 -1
- package/src/assets/web-panel/assets/{Trigger-ChNLLypd.js → Trigger-C5sgb3C7.js} +1 -1
- package/src/assets/web-panel/assets/{Trust-dlVGUKs5.js → Trust-H2vOb3Rb.js} +1 -1
- package/src/assets/web-panel/assets/{UkeySign-BXyBUhNF.js → UkeySign-NmPtHK2u.js} +1 -1
- package/src/assets/web-panel/assets/{VideoEditing-BE8-68ge.js → VideoEditing-DHv-Ti7K.js} +1 -1
- package/src/assets/web-panel/assets/{Wallet-7ZIJOwNg.js → Wallet-BYZ3-PRP.js} +4 -4
- package/src/assets/web-panel/assets/{WebAuthn-DgNd2Amr.js → WebAuthn-BaSlP7om.js} +4 -4
- package/src/assets/web-panel/assets/{WorkflowEditor-Z-Un-9l5.js → WorkflowEditor-CZ7IHuIi.js} +1 -1
- package/src/assets/web-panel/assets/{chat-CtVDEkUc.js → chat-Dd9kY8gn.js} +1 -1
- package/src/assets/web-panel/assets/{colors-PXtJ2f1B.js → colors-yhzo6QSV.js} +1 -1
- package/src/assets/web-panel/assets/{compact-item-oN96nuBi.js → compact-item-C1JneVdM.js} +1 -1
- package/src/assets/web-panel/assets/{createContext-okCJXQXa.js → createContext-C_waVCg2.js} +1 -1
- package/src/assets/web-panel/assets/devWarning-DaDlyVDY.js +1 -0
- package/src/assets/web-panel/assets/{hasIn-pcHm--4o.js → hasIn-CkVg0Xnh.js} +1 -1
- package/src/assets/web-panel/assets/{index-BeAWCGEK.js → index-1VYhIr6D.js} +1 -1
- package/src/assets/web-panel/assets/{index-DC0bqrqV.js → index-BTMiVIBI.js} +1 -1
- package/src/assets/web-panel/assets/{index-CQYes2mk.js → index-BYOyb5X2.js} +1 -1
- package/src/assets/web-panel/assets/{index-vW0lwj0Y.js → index-BjDnI7oM.js} +1 -1
- package/src/assets/web-panel/assets/{index-BUUJ2WfG.js → index-BlJNvg-2.js} +1 -1
- package/src/assets/web-panel/assets/{index-C0rfJBU3.js → index-BlzbSUK9.js} +1 -1
- package/src/assets/web-panel/assets/{index-Rk0X8lVz.js → index-BrN2hAeE.js} +1 -1
- package/src/assets/web-panel/assets/{index-BDR2mi1p.js → index-BsSsurcn.js} +1 -1
- package/src/assets/web-panel/assets/index-BtsLExcc.js +1 -0
- package/src/assets/web-panel/assets/{index-jAY55MSx.js → index-Bwuu3Bf3.js} +1 -1
- package/src/assets/web-panel/assets/{index-_SaFHNsG.js → index-Bx3A6X4J.js} +1 -1
- package/src/assets/web-panel/assets/{index-T3QE8LuV.js → index-C-Sscgnv.js} +1 -1
- package/src/assets/web-panel/assets/index-C3qDvvIb.js +1 -0
- package/src/assets/web-panel/assets/{index-BUTyx-Lk.js → index-C4HNdMCt.js} +1 -1
- package/src/assets/web-panel/assets/{index-nMqLdwao.js → index-CBwmwTNa.js} +1 -1
- package/src/assets/web-panel/assets/{index-BuME9k4Y.js → index-CErewFeT.js} +1 -1
- package/src/assets/web-panel/assets/{index-BHJIfdCV.js → index-CGtoL5mR.js} +1 -1
- package/src/assets/web-panel/assets/{index-D0bfypsF.js → index-CUDjGt_V.js} +1 -1
- package/src/assets/web-panel/assets/{index-Bl6N7ZyX.js → index-C_qqYZT7.js} +1 -1
- package/src/assets/web-panel/assets/{index-CXhezK4d.js → index-C_riAa6d.js} +1 -1
- package/src/assets/web-panel/assets/{index-BnhHxZul.js → index-CfEMjbhu.js} +1 -1
- package/src/assets/web-panel/assets/{index-Cs1fGk1Z.js → index-ClQhh6v3.js} +1 -1
- package/src/assets/web-panel/assets/{index-BjVIZfdA.js → index-CmiLmG0i.js} +1 -1
- package/src/assets/web-panel/assets/{index-CWUJlCvK.js → index-D1xIbAxQ.js} +3 -3
- package/src/assets/web-panel/assets/{index-DfL10EcN.js → index-D6UXDH2D.js} +1 -1
- package/src/assets/web-panel/assets/{index-C4ravjdv.js → index-D7kQ_4uL.js} +1 -1
- package/src/assets/web-panel/assets/{index-DtsyPnF5.js → index-DPkqQ2Pr.js} +1 -1
- package/src/assets/web-panel/assets/{index-CQ1ZzJjN.js → index-DUA96wEj.js} +1 -1
- package/src/assets/web-panel/assets/{index-5aO2V0V4.js → index-DXHk9Vof.js} +1 -1
- package/src/assets/web-panel/assets/{index-CopGaCI_.js → index-DapftLUr.js} +1 -1
- package/src/assets/web-panel/assets/{index-D1efSh1X.js → index-Dez7TUgG.js} +1 -1
- package/src/assets/web-panel/assets/{index-DBcKBb1E.js → index-DhZgULsY.js} +1 -1
- package/src/assets/web-panel/assets/{index-Bht9n8Sm.js → index-Diquc7-l.js} +1 -1
- package/src/assets/web-panel/assets/{index-CAQktO-6.js → index-DuGtLiTW.js} +1 -1
- package/src/assets/web-panel/assets/{index-CK0H3pQW.js → index-U_nLXGIx.js} +1 -1
- package/src/assets/web-panel/assets/{index-BZKk4Lnm.js → index-_M8El5iz.js} +1 -1
- package/src/assets/web-panel/assets/{index-Cc_MkksW.js → index-_VmzCYFn.js} +1 -1
- package/src/assets/web-panel/assets/{index-mB1UV7wA.js → index-_rZxgFtc.js} +1 -1
- package/src/assets/web-panel/assets/{index-BEtzqJw3.js → index-ewb0-LoH.js} +1 -1
- package/src/assets/web-panel/assets/{initDefaultProps-CG1jyiI7.js → initDefaultProps-DNN6qtGa.js} +1 -1
- package/src/assets/web-panel/assets/{motion-C8BYa98D.js → motion-BcM9exeo.js} +1 -1
- package/src/assets/web-panel/assets/{move-D1hqwitW.js → move-DsKJ-YvA.js} +1 -1
- package/src/assets/web-panel/assets/{mtc-parser-C2lol8rp.js → mtc-parser-glgGkxaK.js} +1 -1
- package/src/assets/web-panel/assets/{omit-Ct6Zghfi.js → omit-BwHCU50S.js} +1 -1
- package/src/assets/web-panel/assets/{pickAttrs-CSYbYfdc.js → pickAttrs-BVp-bLt7.js} +1 -1
- package/src/assets/web-panel/assets/{placementArrow-WCk8di0z.js → placementArrow-CZNe0r-w.js} +1 -1
- package/src/assets/web-panel/assets/{responsiveObserve-CEC8CZ-3.js → responsiveObserve-DUafj9pU.js} +1 -1
- package/src/assets/web-panel/assets/{slide-BsXyd7Lq.js → slide-H4NqXQxu.js} +1 -1
- package/src/assets/web-panel/assets/{statusUtils-tDKldiUT.js → statusUtils-UjMzzGZ5.js} +1 -1
- package/src/assets/web-panel/assets/{styleChecker-BIS2unhx.js → styleChecker-qO5aCjyk.js} +1 -1
- package/src/assets/web-panel/assets/{useFlexGapSupport-yFTVMDUv.js → useFlexGapSupport-ChecYwC8.js} +1 -1
- package/src/assets/web-panel/assets/{useFs-CZYbJwkO.js → useFs-BR0QvwO3.js} +1 -1
- package/src/assets/web-panel/assets/{usePersonalDataHub-sbEHRLAM.js → usePersonalDataHub-DitK2Qgz.js} +1 -1
- package/src/assets/web-panel/assets/{vnode-BVjeSEQK.js → vnode-dxSb40dY.js} +1 -1
- package/src/assets/web-panel/assets/{zoom-BZKVvEZ6.js → zoom-ekJk8YB2.js} +1 -1
- package/src/assets/web-panel/index.html +1 -1
- package/src/commands/agent.js +75 -0
- package/src/commands/session.js +132 -0
- package/src/data/changelog.json +8 -2
- package/src/harness/jsonl-session-store.js +9 -0
- package/src/harness/session-mirror.js +212 -6
- package/src/harness/worktree-isolator.js +10 -2
- package/src/lib/agent-core.js +1 -0
- package/src/lib/async-hook-queue.cjs +288 -0
- package/src/lib/async-hook-supervisor.cjs +52 -0
- package/src/lib/background-phase-reporter.js +116 -0
- package/src/lib/doctor-checkup.js +184 -4
- package/src/lib/hook-runner.cjs +248 -36
- package/src/lib/install-command-policy.js +343 -0
- package/src/lib/jsonl-session-store.js +1 -0
- package/src/lib/lsp/__tests__/lsp-server-registry.test.js +27 -0
- package/src/lib/lsp/code-intelligence.js +5 -1
- package/src/lib/lsp/lsp-server-registry.js +14 -0
- package/src/lib/lsp/workspace-roots.js +115 -0
- package/src/lib/runtime-checkup.js +33 -0
- package/src/lib/settings-hook-events.cjs +173 -3
- package/src/lib/settings-hooks.cjs +4 -0
- package/src/lib/shell-approval.js +47 -1
- package/src/lib/sub-agent-context.js +13 -0
- package/src/repl/add-dir.js +59 -1
- package/src/repl/agent-repl.js +85 -3
- package/src/runtime/agent-core.js +411 -82
- package/src/runtime/headless-runner.js +111 -9
- package/src/runtime/headless-stream.js +39 -0
- package/src/runtime/system-prompt.js +39 -12
- package/src/workers/background-agent-worker.js +8 -0
- package/src/assets/web-panel/assets/MarkdownRenderer-BL_WsWZM.js +0 -1
- package/src/assets/web-panel/assets/MobileProjects-Bp5QB-46.js +0 -1
- package/src/assets/web-panel/assets/Terminal-ClPrfWW7.js +0 -3
- package/src/assets/web-panel/assets/devWarning-BzV6dk1B.js +0 -1
- package/src/assets/web-panel/assets/index-BFrAwXHp.js +0 -1
- package/src/assets/web-panel/assets/index-DJQFP47u.js +0 -1
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* async-hook-queue — a small, best-effort, PERSISTENT queue for async settings
|
|
5
|
+
* hooks that opted into `asyncRewake: true` and finished in a FAILURE state.
|
|
6
|
+
*
|
|
7
|
+
* Why: AsyncHookSupervisor runs `async: true` hooks fire-and-forget and keeps
|
|
8
|
+
* their results ONLY in memory (`_results`/`_rewakes`). A rewake is the one
|
|
9
|
+
* actionable signal it produces — "a background check (e.g. `npm test`) failed,
|
|
10
|
+
* re-engage the agent with the error". If the process dies (crash, kill,
|
|
11
|
+
* hard exit) after the rewake was recorded but BEFORE the turn loop drained and
|
|
12
|
+
* surfaced it, that signal is silently lost: the next `--resume` of the same
|
|
13
|
+
* session has no idea a background check failed.
|
|
14
|
+
*
|
|
15
|
+
* This module gives the supervisor a durable place to park undrained rewakes,
|
|
16
|
+
* keyed by session id, so `--resume <id>` can recover them. The gap's P2 "Hooks"
|
|
17
|
+
* item calls for an "async hook 持久队列"; this is the reliability half of it
|
|
18
|
+
* (paired with the already-shipped `allowManagedHooksOnly`).
|
|
19
|
+
*
|
|
20
|
+
* Lifecycle (rewake-scoped on purpose — non-rewake results are informational and
|
|
21
|
+
* cheap to lose; rewakes are the failure signal worth surviving a crash):
|
|
22
|
+
* record a rewake → appendRewake() (immediate write, survives even SIGKILL)
|
|
23
|
+
* drain / surface → removeRewakes() (consumed → no longer pending)
|
|
24
|
+
* resume a session → takePending() (load + clear that session's bucket)
|
|
25
|
+
*
|
|
26
|
+
* Design mirrors hook-stats-store.cjs: pure folds (no clock/RNG/fs inside), an
|
|
27
|
+
* injected fs for load/save, atomic tmp+rename writes, and per-session +
|
|
28
|
+
* per-session-count bounds so a runaway producer never grows the file without
|
|
29
|
+
* bound. Every path is best-effort — a corrupt/io-failed queue file must never
|
|
30
|
+
* block a hook or the agent.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const os = require("node:os");
|
|
34
|
+
const path = require("node:path");
|
|
35
|
+
const fsDefault = require("node:fs");
|
|
36
|
+
|
|
37
|
+
const CONFIG_DIR_NAME = ".chainlesschain";
|
|
38
|
+
const MAX_RECORDS_PER_SESSION = 200; // drop oldest-recorded beyond this
|
|
39
|
+
const MAX_SESSIONS = 100; // bound distinct session buckets
|
|
40
|
+
const DEFAULT_STALE_MS = 7 * 24 * 60 * 60 * 1000; // prune buckets untouched >7d
|
|
41
|
+
|
|
42
|
+
/** Default on-disk location (matches the ESM getHomeDir() layout). */
|
|
43
|
+
function defaultAsyncHookQueuePath() {
|
|
44
|
+
return path.join(os.homedir(), CONFIG_DIR_NAME, "async-hook-queue.json");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Stable de-dupe key for one rewake record. Two records with the same
|
|
49
|
+
* (command, event, ts, ms, exitCode) are treated as the same pending item —
|
|
50
|
+
* enough to make removeRewakes() idempotent and appendRewake() not double-store
|
|
51
|
+
* a record that a caller replays. Pure, no hashing needed.
|
|
52
|
+
*/
|
|
53
|
+
function queueEntryKey(rec) {
|
|
54
|
+
if (!rec || typeof rec !== "object") return "";
|
|
55
|
+
return [
|
|
56
|
+
String(rec.command || ""),
|
|
57
|
+
String(rec.event || ""),
|
|
58
|
+
String(rec.ts ?? ""),
|
|
59
|
+
String(rec.ms ?? ""),
|
|
60
|
+
String(rec.exitCode ?? ""),
|
|
61
|
+
].join("|");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Normalize a raw supervisor record into a compact stored rewake entry. */
|
|
65
|
+
function normalizeRecord(rec) {
|
|
66
|
+
if (!rec || typeof rec !== "object") return null;
|
|
67
|
+
const command = String(rec.command || "").trim();
|
|
68
|
+
if (!command) return null;
|
|
69
|
+
return {
|
|
70
|
+
command,
|
|
71
|
+
event: rec.event ?? null,
|
|
72
|
+
exitCode: rec.exitCode ?? null,
|
|
73
|
+
error: rec.error ? String(rec.error) : null,
|
|
74
|
+
additionalContext: rec.additionalContext
|
|
75
|
+
? String(rec.additionalContext)
|
|
76
|
+
: null,
|
|
77
|
+
blocked: !!rec.blocked,
|
|
78
|
+
ts: Number.isFinite(Number(rec.ts)) ? Number(rec.ts) : 0,
|
|
79
|
+
ms: Number.isFinite(Number(rec.ms)) ? Number(rec.ms) : 0,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Fresh empty queue. */
|
|
84
|
+
function emptyQueue() {
|
|
85
|
+
return { sessions: {} };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Coerce an arbitrary parsed value into the queue shape. */
|
|
89
|
+
function coerceQueue(parsed) {
|
|
90
|
+
if (
|
|
91
|
+
!parsed ||
|
|
92
|
+
typeof parsed !== "object" ||
|
|
93
|
+
typeof parsed.sessions !== "object" ||
|
|
94
|
+
!parsed.sessions
|
|
95
|
+
) {
|
|
96
|
+
return emptyQueue();
|
|
97
|
+
}
|
|
98
|
+
return { sessions: parsed.sessions };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* PURE fold: append `records` into `queue` under `sessionId` (mutates + returns
|
|
103
|
+
* `queue`). De-dupes by queueEntryKey, keeps the most-recent MAX_RECORDS_PER_
|
|
104
|
+
* SESSION, and stamps the bucket's `updatedAt` for stale pruning. `now` is
|
|
105
|
+
* supplied by the caller so the fold stays clock-free.
|
|
106
|
+
*/
|
|
107
|
+
function foldAppend(queue, sessionId, records, now) {
|
|
108
|
+
if (!queue || typeof queue !== "object") queue = emptyQueue();
|
|
109
|
+
if (!queue.sessions) queue.sessions = {};
|
|
110
|
+
const id = String(sessionId || "").trim();
|
|
111
|
+
if (!id) return queue;
|
|
112
|
+
const list = Array.isArray(records) ? records : [records];
|
|
113
|
+
const bucket = queue.sessions[id] || { records: [], updatedAt: 0 };
|
|
114
|
+
const seen = new Set(bucket.records.map(queueEntryKey));
|
|
115
|
+
for (const raw of list) {
|
|
116
|
+
const rec = normalizeRecord(raw);
|
|
117
|
+
if (!rec) continue;
|
|
118
|
+
const key = queueEntryKey(rec);
|
|
119
|
+
if (seen.has(key)) continue;
|
|
120
|
+
seen.add(key);
|
|
121
|
+
bucket.records.push(rec);
|
|
122
|
+
}
|
|
123
|
+
// Never persist an empty bucket (all-invalid input, or nothing new for a
|
|
124
|
+
// session that had none) — it would just be dead weight in the file.
|
|
125
|
+
if (bucket.records.length === 0) return queue;
|
|
126
|
+
if (bucket.records.length > MAX_RECORDS_PER_SESSION) {
|
|
127
|
+
bucket.records = bucket.records.slice(
|
|
128
|
+
bucket.records.length - MAX_RECORDS_PER_SESSION,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
bucket.updatedAt = Number.isFinite(Number(now))
|
|
132
|
+
? Number(now)
|
|
133
|
+
: bucket.updatedAt;
|
|
134
|
+
queue.sessions[id] = bucket;
|
|
135
|
+
boundSessions(queue);
|
|
136
|
+
return queue;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* PURE fold: remove `records` (by key) from `sessionId`'s bucket (mutates +
|
|
141
|
+
* returns `queue`). Empties → the bucket is deleted so the file self-cleans.
|
|
142
|
+
*/
|
|
143
|
+
function foldRemove(queue, sessionId, records) {
|
|
144
|
+
if (!queue || !queue.sessions) return queue || emptyQueue();
|
|
145
|
+
const id = String(sessionId || "").trim();
|
|
146
|
+
const bucket = queue.sessions[id];
|
|
147
|
+
if (!bucket) return queue;
|
|
148
|
+
const kill = new Set(
|
|
149
|
+
(Array.isArray(records) ? records : [records])
|
|
150
|
+
.map(normalizeRecord)
|
|
151
|
+
.filter(Boolean)
|
|
152
|
+
.map(queueEntryKey),
|
|
153
|
+
);
|
|
154
|
+
bucket.records = bucket.records.filter((r) => !kill.has(queueEntryKey(r)));
|
|
155
|
+
if (bucket.records.length === 0) delete queue.sessions[id];
|
|
156
|
+
return queue;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Keep only the MAX_SESSIONS most-recently-updated buckets. */
|
|
160
|
+
function boundSessions(queue) {
|
|
161
|
+
if (!queue || !queue.sessions) return queue;
|
|
162
|
+
const ids = Object.keys(queue.sessions);
|
|
163
|
+
if (ids.length <= MAX_SESSIONS) return queue;
|
|
164
|
+
ids
|
|
165
|
+
.sort(
|
|
166
|
+
(a, b) =>
|
|
167
|
+
(queue.sessions[a].updatedAt || 0) - (queue.sessions[b].updatedAt || 0),
|
|
168
|
+
)
|
|
169
|
+
.slice(0, ids.length - MAX_SESSIONS)
|
|
170
|
+
.forEach((id) => delete queue.sessions[id]);
|
|
171
|
+
return queue;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* PURE fold: drop buckets whose `updatedAt` is older than `now - staleMs`
|
|
176
|
+
* (mutates + returns `queue`). Guards the file against unbounded growth from
|
|
177
|
+
* sessions that were never resumed to drain their pending rewakes.
|
|
178
|
+
*/
|
|
179
|
+
function foldPruneStale(queue, now, staleMs = DEFAULT_STALE_MS) {
|
|
180
|
+
if (!queue || !queue.sessions) return queue || emptyQueue();
|
|
181
|
+
const cutoff = (Number.isFinite(Number(now)) ? Number(now) : 0) - staleMs;
|
|
182
|
+
for (const [id, bucket] of Object.entries(queue.sessions)) {
|
|
183
|
+
if (!bucket || (bucket.updatedAt || 0) < cutoff) delete queue.sessions[id];
|
|
184
|
+
}
|
|
185
|
+
return queue;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Best-effort read of the on-disk queue (→ empty on missing/corrupt). */
|
|
189
|
+
function loadQueue(file = defaultAsyncHookQueuePath(), fs = fsDefault) {
|
|
190
|
+
try {
|
|
191
|
+
if (!fs.existsSync(file)) return emptyQueue();
|
|
192
|
+
return coerceQueue(JSON.parse(fs.readFileSync(file, "utf-8")));
|
|
193
|
+
} catch {
|
|
194
|
+
return emptyQueue();
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Best-effort atomic write (tmp + rename). Never throws. */
|
|
199
|
+
function saveQueue(queue, file = defaultAsyncHookQueuePath(), fs = fsDefault) {
|
|
200
|
+
try {
|
|
201
|
+
const dir = path.dirname(file);
|
|
202
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
203
|
+
const tmp = `${file}.tmp`;
|
|
204
|
+
fs.writeFileSync(tmp, JSON.stringify(queue), "utf-8");
|
|
205
|
+
fs.renameSync(tmp, file);
|
|
206
|
+
return true;
|
|
207
|
+
} catch {
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Append one/more rewake records for `sessionId` and write back. Best-effort;
|
|
214
|
+
* returns true on a successful write. `now` defaults to Date.now() (the only
|
|
215
|
+
* non-pure entry point besides load/save).
|
|
216
|
+
*/
|
|
217
|
+
function appendRewake(
|
|
218
|
+
{ sessionId, records, now } = {},
|
|
219
|
+
file = defaultAsyncHookQueuePath(),
|
|
220
|
+
fs = fsDefault,
|
|
221
|
+
) {
|
|
222
|
+
const id = String(sessionId || "").trim();
|
|
223
|
+
if (!id || records == null) return false;
|
|
224
|
+
const at = Number.isFinite(Number(now)) ? Number(now) : Date.now();
|
|
225
|
+
const queue = foldAppend(loadQueue(file, fs), id, records, at);
|
|
226
|
+
return saveQueue(queue, file, fs);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Remove (mark consumed) rewake records for `sessionId` and write back.
|
|
231
|
+
* Best-effort; a no-op when nothing matches.
|
|
232
|
+
*/
|
|
233
|
+
function removeRewakes(
|
|
234
|
+
{ sessionId, records } = {},
|
|
235
|
+
file = defaultAsyncHookQueuePath(),
|
|
236
|
+
fs = fsDefault,
|
|
237
|
+
) {
|
|
238
|
+
const id = String(sessionId || "").trim();
|
|
239
|
+
if (!id || records == null) return false;
|
|
240
|
+
const queue = foldRemove(loadQueue(file, fs), id, records);
|
|
241
|
+
return saveQueue(queue, file, fs);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Load a session's pending rewakes AND clear its bucket (also prunes stale
|
|
246
|
+
* buckets while we hold the file). Returns the recovered records (possibly []).
|
|
247
|
+
* The single call a resuming runner makes on startup.
|
|
248
|
+
*/
|
|
249
|
+
function takePending(
|
|
250
|
+
{ sessionId, now, staleMs } = {},
|
|
251
|
+
file = defaultAsyncHookQueuePath(),
|
|
252
|
+
fs = fsDefault,
|
|
253
|
+
) {
|
|
254
|
+
const id = String(sessionId || "").trim();
|
|
255
|
+
if (!id) return [];
|
|
256
|
+
const at = Number.isFinite(Number(now)) ? Number(now) : Date.now();
|
|
257
|
+
let queue = loadQueue(file, fs);
|
|
258
|
+
const bucket = queue.sessions[id];
|
|
259
|
+
const recovered =
|
|
260
|
+
bucket && Array.isArray(bucket.records) ? bucket.records : [];
|
|
261
|
+
if (bucket) delete queue.sessions[id];
|
|
262
|
+
queue = foldPruneStale(queue, at, staleMs);
|
|
263
|
+
// Only rewrite if we actually changed something (recovered a bucket or the
|
|
264
|
+
// prune dropped one) — a pure read of an empty/unrelated queue leaves the
|
|
265
|
+
// file byte-unchanged.
|
|
266
|
+
if (recovered.length > 0 || bucket) saveQueue(queue, file, fs);
|
|
267
|
+
return recovered;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
module.exports = {
|
|
271
|
+
defaultAsyncHookQueuePath,
|
|
272
|
+
queueEntryKey,
|
|
273
|
+
normalizeRecord,
|
|
274
|
+
emptyQueue,
|
|
275
|
+
coerceQueue,
|
|
276
|
+
foldAppend,
|
|
277
|
+
foldRemove,
|
|
278
|
+
foldPruneStale,
|
|
279
|
+
boundSessions,
|
|
280
|
+
loadQueue,
|
|
281
|
+
saveQueue,
|
|
282
|
+
appendRewake,
|
|
283
|
+
removeRewakes,
|
|
284
|
+
takePending,
|
|
285
|
+
MAX_RECORDS_PER_SESSION,
|
|
286
|
+
MAX_SESSIONS,
|
|
287
|
+
DEFAULT_STALE_MS,
|
|
288
|
+
};
|
|
@@ -73,6 +73,22 @@ class AsyncHookSupervisor {
|
|
|
73
73
|
? this._statsStore.defaultHookStatsPath()
|
|
74
74
|
: null);
|
|
75
75
|
this._statsFs = opts.statsFs || null; // injected fs for tests
|
|
76
|
+
// Persistent rewake queue (opt-in). A rewake — an async check that OPTED IN
|
|
77
|
+
// and finished in a FAILURE state — is the one actionable signal this
|
|
78
|
+
// supervisor produces, but it lives only in `_rewakes` until the turn loop
|
|
79
|
+
// drains it. If the process dies in that window the failure is lost. When a
|
|
80
|
+
// `sessionId` + `persistQueue` (or an explicit `queuePath`) are supplied,
|
|
81
|
+
// each rewake is durably parked keyed by session so `--resume` can recover
|
|
82
|
+
// it; it's removed again the moment the run drains it. Disabled (no writes,
|
|
83
|
+
// byte-unchanged) when either is absent — mirrors the stats-store opt-in.
|
|
84
|
+
this._queueStore = opts.queueStore || require("./async-hook-queue.cjs");
|
|
85
|
+
this._queueSessionId = opts.sessionId ? String(opts.sessionId) : null;
|
|
86
|
+
this._queuePath =
|
|
87
|
+
opts.queuePath ||
|
|
88
|
+
(opts.persistQueue === true
|
|
89
|
+
? this._queueStore.defaultAsyncHookQueuePath()
|
|
90
|
+
: null);
|
|
91
|
+
this._queueFs = opts.queueFs || null; // injected fs for tests
|
|
76
92
|
// The exit reaper is installed LAZILY (on first spawn), not in the ctor:
|
|
77
93
|
// a supervisor that never dispatches a hook (or the many built in tests)
|
|
78
94
|
// must not leave a permanent `process.once('exit')` listener behind —
|
|
@@ -359,6 +375,29 @@ class AsyncHookSupervisor {
|
|
|
359
375
|
if (hook.asyncRewake && !rec.ok) {
|
|
360
376
|
this._rewakes.push(rec);
|
|
361
377
|
if (this._rewakes.length > RESULT_RING) this._rewakes.shift();
|
|
378
|
+
// Durably park it (opt-in) so a crash before the turn loop drains this
|
|
379
|
+
// rewake doesn't lose the failure signal. Immediate write, not deferred to
|
|
380
|
+
// stopAll — a rewake must survive even a hard exit that never runs stopAll.
|
|
381
|
+
this._persistRewake(rec);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** True when a session id + queue location were both configured. */
|
|
386
|
+
_queueEnabled() {
|
|
387
|
+
return !!(this._queueSessionId && this._queuePath);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Best-effort durable append of one rewake record. Never throws. */
|
|
391
|
+
_persistRewake(rec) {
|
|
392
|
+
if (!this._queueEnabled()) return;
|
|
393
|
+
try {
|
|
394
|
+
this._queueStore.appendRewake(
|
|
395
|
+
{ sessionId: this._queueSessionId, records: [rec], now: rec.ts },
|
|
396
|
+
this._queuePath,
|
|
397
|
+
this._queueFs || undefined,
|
|
398
|
+
);
|
|
399
|
+
} catch {
|
|
400
|
+
/* queue is best-effort */
|
|
362
401
|
}
|
|
363
402
|
}
|
|
364
403
|
|
|
@@ -377,6 +416,19 @@ class AsyncHookSupervisor {
|
|
|
377
416
|
drainRewakes() {
|
|
378
417
|
const r = this._rewakes;
|
|
379
418
|
this._rewakes = [];
|
|
419
|
+
// The run has now consumed these rewakes (it will surface / re-engage on
|
|
420
|
+
// them), so they no longer need to survive a crash — drop the durable copies.
|
|
421
|
+
if (r.length > 0 && this._queueEnabled()) {
|
|
422
|
+
try {
|
|
423
|
+
this._queueStore.removeRewakes(
|
|
424
|
+
{ sessionId: this._queueSessionId, records: r },
|
|
425
|
+
this._queuePath,
|
|
426
|
+
this._queueFs || undefined,
|
|
427
|
+
);
|
|
428
|
+
} catch {
|
|
429
|
+
/* queue is best-effort */
|
|
430
|
+
}
|
|
431
|
+
}
|
|
380
432
|
return r;
|
|
381
433
|
}
|
|
382
434
|
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* background-phase-reporter — the missing PRODUCER for the background-agent
|
|
3
|
+
* `waiting_permission` phase (P0 state-machine slice,
|
|
4
|
+
* CLAUDE_CODE_CLI_INCREMENTAL_GAP_ANALYSIS_2026-07-12 §"后台 Agent 状态机").
|
|
5
|
+
*
|
|
6
|
+
* The consuming contract has existed for a while: `background-agent-phase.js`
|
|
7
|
+
* normalizes `waiting_permission` and `pendingApprovals > 0` into the
|
|
8
|
+
* dashboard's "Needs input" group, and `session-lifecycle.js` folds them into
|
|
9
|
+
* `waitingApproval`. But nothing ever WROTE those fields — the worker only
|
|
10
|
+
* emits `turn`/`idle`, so a background agent blocked on a human approval
|
|
11
|
+
* (`--permission-prompt-tool`, `--remote-control`) showed as "Working"
|
|
12
|
+
* indefinitely while a decision was actually pending.
|
|
13
|
+
*
|
|
14
|
+
* The production seam is the agent CHILD process, not the worker: the worker
|
|
15
|
+
* spawns each turn with `CC_BACKGROUND_AGENT_ID` in the environment, and the
|
|
16
|
+
* only places a plain headless run genuinely blocks on a human are the
|
|
17
|
+
* confirmer overrides installed on the ApprovalGate. This module wraps those
|
|
18
|
+
* confirmers: while one is awaiting a decision it patches the shared state
|
|
19
|
+
* file with `phase: "waiting_permission"` + a live `pendingApprovals` count,
|
|
20
|
+
* and restores `phase: "turn"` when every pending approval has settled.
|
|
21
|
+
*
|
|
22
|
+
* Safety properties:
|
|
23
|
+
* - Disabled (no `CC_BACKGROUND_AGENT_ID`) → `wrapConfirmer` returns the
|
|
24
|
+
* SAME function object; zero reads, zero writes, byte-identical behavior
|
|
25
|
+
* for every non-background run.
|
|
26
|
+
* - Never resurrects a terminal session: writes are skipped when the current
|
|
27
|
+
* on-disk status is anything other than absent/"running" (mirrors the
|
|
28
|
+
* worker's own writeHeartbeat guard).
|
|
29
|
+
* - Best-effort throughout: a state-file hiccup must never break an approval.
|
|
30
|
+
* - IO is injectable so tests never touch the real user data dir.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import {
|
|
34
|
+
readBackgroundAgentState,
|
|
35
|
+
writeBackgroundAgentState,
|
|
36
|
+
} from "./background-agent-supervisor.js";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Create a phase reporter for the current process.
|
|
40
|
+
*
|
|
41
|
+
* @param {object} [opts]
|
|
42
|
+
* @param {string|null} [opts.agentId] background session id; defaults to
|
|
43
|
+
* `process.env.CC_BACKGROUND_AGENT_ID`. Falsy → disabled no-op reporter.
|
|
44
|
+
* @param {(id:string)=>object|null} [opts.readState] injectable state reader
|
|
45
|
+
* @param {(state:object)=>void} [opts.writeState] injectable state writer
|
|
46
|
+
*/
|
|
47
|
+
export function createBackgroundPhaseReporter(opts = {}) {
|
|
48
|
+
const agentId =
|
|
49
|
+
opts.agentId !== undefined
|
|
50
|
+
? opts.agentId
|
|
51
|
+
: process.env.CC_BACKGROUND_AGENT_ID;
|
|
52
|
+
const readState = opts.readState || readBackgroundAgentState;
|
|
53
|
+
const writeState = opts.writeState || writeBackgroundAgentState;
|
|
54
|
+
const enabled = typeof agentId === "string" && agentId.trim() !== "";
|
|
55
|
+
let pending = 0;
|
|
56
|
+
|
|
57
|
+
function patch(fields) {
|
|
58
|
+
if (!enabled) return false;
|
|
59
|
+
try {
|
|
60
|
+
const current = readState(agentId) || { id: agentId };
|
|
61
|
+
// Terminal states are owned by the worker/stopper — never write a
|
|
62
|
+
// "waiting" phase over a completed/failed/stopped record.
|
|
63
|
+
if (current.status && current.status !== "running") return false;
|
|
64
|
+
writeState({ ...current, id: agentId, ...fields });
|
|
65
|
+
return true;
|
|
66
|
+
} catch {
|
|
67
|
+
return false; // state persistence must never break the approval itself
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
enabled,
|
|
73
|
+
/** Current in-process pending-approval count (for tests/introspection). */
|
|
74
|
+
pendingCount() {
|
|
75
|
+
return pending;
|
|
76
|
+
},
|
|
77
|
+
/** A human approval just started blocking progress. */
|
|
78
|
+
beginApproval() {
|
|
79
|
+
if (!enabled) return;
|
|
80
|
+
pending++;
|
|
81
|
+
patch({
|
|
82
|
+
phase: "waiting_permission",
|
|
83
|
+
pendingApprovals: pending,
|
|
84
|
+
});
|
|
85
|
+
},
|
|
86
|
+
/** A previously reported approval settled (approved, denied, or failed). */
|
|
87
|
+
endApproval() {
|
|
88
|
+
if (!enabled) return;
|
|
89
|
+
pending = Math.max(0, pending - 1);
|
|
90
|
+
if (pending === 0) {
|
|
91
|
+
// Back to a live turn — the worker owns the turn/idle transition, we
|
|
92
|
+
// only hand the phase back to its steady in-turn value.
|
|
93
|
+
patch({ phase: "turn", pendingApprovals: 0 });
|
|
94
|
+
} else {
|
|
95
|
+
patch({ pendingApprovals: pending });
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
/**
|
|
99
|
+
* Wrap a (potentially human-blocking) confirmer so the pending window is
|
|
100
|
+
* visible in the background state. Disabled → returns `confirmer`
|
|
101
|
+
* unchanged (same object identity, zero overhead).
|
|
102
|
+
*/
|
|
103
|
+
wrapConfirmer(confirmer) {
|
|
104
|
+
if (!enabled || typeof confirmer !== "function") return confirmer;
|
|
105
|
+
const self = this;
|
|
106
|
+
return async function phaseReportingConfirmer(...args) {
|
|
107
|
+
self.beginApproval();
|
|
108
|
+
try {
|
|
109
|
+
return await confirmer(...args);
|
|
110
|
+
} finally {
|
|
111
|
+
self.endApproval();
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -26,6 +26,11 @@ import {
|
|
|
26
26
|
import { execSync, spawnSync } from "node:child_process";
|
|
27
27
|
import { join, basename } from "node:path";
|
|
28
28
|
import { getHomeDir, getConfigPath } from "./paths.js";
|
|
29
|
+
import {
|
|
30
|
+
languageIdForFile,
|
|
31
|
+
resolveServer,
|
|
32
|
+
describeServer,
|
|
33
|
+
} from "./lsp/lsp-server-registry.js";
|
|
29
34
|
import {
|
|
30
35
|
checkAgenda,
|
|
31
36
|
checkInstructionFiles,
|
|
@@ -33,6 +38,8 @@ import {
|
|
|
33
38
|
checkHooks,
|
|
34
39
|
checkSandbox,
|
|
35
40
|
checkDuplicateSkills,
|
|
41
|
+
checkLspReadiness,
|
|
42
|
+
checkOrphanProcesses,
|
|
36
43
|
DEFAULT_CHECKUP_THRESHOLDS,
|
|
37
44
|
} from "./runtime-checkup.js";
|
|
38
45
|
|
|
@@ -604,16 +611,84 @@ async function worktreeSection(opts, deps) {
|
|
|
604
611
|
|
|
605
612
|
// ── runtime checkup: agenda expiry + verbose instruction files ──────────────
|
|
606
613
|
// Only the gaps the OTHER sections don't already cover. Stale sessions,
|
|
607
|
-
// worktrees
|
|
608
|
-
// transcriptSection / worktreeSection / backgroundSection — surfacing
|
|
609
|
-
// too would double-report
|
|
610
|
-
//
|
|
614
|
+
// worktrees and lost background agents (worker STATUS) are handled by
|
|
615
|
+
// transcriptSection / worktreeSection / backgroundSection — surfacing those
|
|
616
|
+
// here too would double-report. This section covers the agenda schedule store,
|
|
617
|
+
// instruction files (cc.md / AGENTS.md / CLAUDE.md), and — distinct from
|
|
618
|
+
// backgroundSection's worker-status check — leaked agent CHILD subprocesses
|
|
619
|
+
// (a per-turn agentPid still alive after its worker died), probed via a static
|
|
620
|
+
// one-shot PID-liveness check and the shared checkOrphanProcesses evaluator.
|
|
611
621
|
const SEVERITY_TO_LEVEL = {
|
|
612
622
|
error: CHECK_LEVELS.ERR,
|
|
613
623
|
warn: CHECK_LEVELS.WARN,
|
|
614
624
|
info: CHECK_LEVELS.INFO,
|
|
615
625
|
};
|
|
616
626
|
|
|
627
|
+
// Heavy / generated directories a project-language scan must never descend into
|
|
628
|
+
// (they'd dominate the budget and aren't the user's source).
|
|
629
|
+
const LSP_SCAN_SKIP_DIRS = new Set([
|
|
630
|
+
"node_modules",
|
|
631
|
+
"dist",
|
|
632
|
+
"build",
|
|
633
|
+
"out",
|
|
634
|
+
"target",
|
|
635
|
+
"coverage",
|
|
636
|
+
"vendor",
|
|
637
|
+
"venv",
|
|
638
|
+
"__pycache__",
|
|
639
|
+
]);
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Bounded shallow walk of a project root, counting source files per LSP
|
|
643
|
+
* languageId (via `languageIdForFile`). Deliberately capped by total entries
|
|
644
|
+
* visited AND depth so `cc doctor` stays fast on a huge monorepo; heavy build /
|
|
645
|
+
* vendor dirs and dot-dirs are skipped. fs access goes through injected `deps`
|
|
646
|
+
* (readdirSync/statSync) so it's unit-testable. Returns a Map languageId→count.
|
|
647
|
+
*/
|
|
648
|
+
function scanProjectLanguages(
|
|
649
|
+
root,
|
|
650
|
+
deps,
|
|
651
|
+
{ maxEntries = 20000, maxDepth = 8 } = {},
|
|
652
|
+
) {
|
|
653
|
+
const counts = new Map();
|
|
654
|
+
let visited = 0;
|
|
655
|
+
const stack = [{ dir: root, depth: 0 }];
|
|
656
|
+
while (stack.length && visited < maxEntries) {
|
|
657
|
+
const { dir, depth } = stack.pop();
|
|
658
|
+
let names;
|
|
659
|
+
try {
|
|
660
|
+
names = deps.readdirSync(dir);
|
|
661
|
+
} catch {
|
|
662
|
+
continue; // unreadable dir — skip, never crash the doctor
|
|
663
|
+
}
|
|
664
|
+
for (const name of names) {
|
|
665
|
+
if (visited >= maxEntries) break;
|
|
666
|
+
visited += 1;
|
|
667
|
+
const lang = languageIdForFile(name);
|
|
668
|
+
if (lang) {
|
|
669
|
+
counts.set(lang, (counts.get(lang) || 0) + 1);
|
|
670
|
+
continue;
|
|
671
|
+
}
|
|
672
|
+
// Not a source file — maybe a directory to descend (bounded). Skip heavy
|
|
673
|
+
// build/vendor trees and dot-dirs (rarely hold the user's own source).
|
|
674
|
+
if (
|
|
675
|
+
depth < maxDepth &&
|
|
676
|
+
!LSP_SCAN_SKIP_DIRS.has(name) &&
|
|
677
|
+
!String(name).startsWith(".")
|
|
678
|
+
) {
|
|
679
|
+
let isDir = false;
|
|
680
|
+
try {
|
|
681
|
+
isDir = deps.statSync(join(dir, name)).isDirectory();
|
|
682
|
+
} catch {
|
|
683
|
+
isDir = false;
|
|
684
|
+
}
|
|
685
|
+
if (isDir) stack.push({ dir: join(dir, name), depth: depth + 1 });
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
return counts;
|
|
690
|
+
}
|
|
691
|
+
|
|
617
692
|
async function runtimeSection(opts, deps) {
|
|
618
693
|
const checks = [];
|
|
619
694
|
const now = deps.now();
|
|
@@ -789,6 +864,111 @@ async function runtimeSection(opts, deps) {
|
|
|
789
864
|
checks.push(failedCheck("sandbox", "sandbox availability", err));
|
|
790
865
|
}
|
|
791
866
|
|
|
867
|
+
// LSP readiness — languages the project actually contains but for which no
|
|
868
|
+
// language server is installed: the silent reason `code_intelligence` /
|
|
869
|
+
// `cc code-intel` degrades to plain text search. A STATIC install-gap check
|
|
870
|
+
// (no server is started); the project scan is bounded so it stays fast.
|
|
871
|
+
try {
|
|
872
|
+
const root = opts.cwd || process.cwd();
|
|
873
|
+
const langCounts = scanProjectLanguages(root, deps);
|
|
874
|
+
if (langCounts.size > 0) {
|
|
875
|
+
const languages = [];
|
|
876
|
+
for (const [languageId, fileCount] of langCounts) {
|
|
877
|
+
const resolved = resolveServer(languageId, root);
|
|
878
|
+
const desc = describeServer(languageId);
|
|
879
|
+
languages.push({
|
|
880
|
+
languageId,
|
|
881
|
+
fileCount,
|
|
882
|
+
available: !!resolved,
|
|
883
|
+
serverId: (resolved && resolved.id) || (desc && desc.id) || null,
|
|
884
|
+
exampleBin: desc && desc.bins ? desc.bins[0] : null,
|
|
885
|
+
});
|
|
886
|
+
}
|
|
887
|
+
for (const f of checkLspReadiness(languages)) {
|
|
888
|
+
checks.push(
|
|
889
|
+
check(
|
|
890
|
+
f.id,
|
|
891
|
+
`lsp ${f.ref}`,
|
|
892
|
+
SEVERITY_TO_LEVEL[f.severity] || CHECK_LEVELS.INFO,
|
|
893
|
+
`${f.message} — ${f.remediation}`,
|
|
894
|
+
),
|
|
895
|
+
);
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
} catch (err) {
|
|
899
|
+
checks.push(failedCheck("lsp-readiness", "lsp server readiness", err));
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
// Orphaned agent subprocesses — a background worker died but its per-turn
|
|
903
|
+
// agent CHILD (the LLM/tool subprocess recorded as `agentPid`) is still
|
|
904
|
+
// alive: a leaked process nothing reaped. backgroundSection reports the
|
|
905
|
+
// worker's STATUS; this probes the actual child PID's liveness (static,
|
|
906
|
+
// one-shot, anchored on the process start time to defeat PID reuse) and
|
|
907
|
+
// feeds the shared checkOrphanProcesses evaluator. Only PIDs we recorded are
|
|
908
|
+
// probed → no false positives, no OS-wide process-table scan. Tests inject
|
|
909
|
+
// opts.backgroundProcesses / opts.activeSessionIds (mirrors
|
|
910
|
+
// opts.skillLayerEntries below).
|
|
911
|
+
try {
|
|
912
|
+
let processes = opts.backgroundProcesses;
|
|
913
|
+
let activeSessionIds = opts.activeSessionIds;
|
|
914
|
+
if (!processes) {
|
|
915
|
+
const bgMod = await import("./background-agent-supervisor.js");
|
|
916
|
+
const agents = bgMod.listBackgroundAgents({ all: true }) || [];
|
|
917
|
+
activeSessionIds = agents
|
|
918
|
+
.filter((a) => a.status === "running")
|
|
919
|
+
.map((a) => a.sessionId)
|
|
920
|
+
.filter(Boolean);
|
|
921
|
+
processes = [];
|
|
922
|
+
for (const a of agents) {
|
|
923
|
+
let state;
|
|
924
|
+
try {
|
|
925
|
+
state = bgMod.readBackgroundAgentState(a.id);
|
|
926
|
+
} catch {
|
|
927
|
+
continue; // unreadable/raced state file — skip
|
|
928
|
+
}
|
|
929
|
+
const agentPid = Number(state?.agentPid);
|
|
930
|
+
if (!Number.isInteger(agentPid) || agentPid <= 0) continue;
|
|
931
|
+
if (agentPid === Number(state?.pid)) continue; // that's the worker itself
|
|
932
|
+
// Alive AND still the same process we launched (not a reused PID).
|
|
933
|
+
const anchor = state.agentStartedAt || state.startedAt;
|
|
934
|
+
if (!bgMod.isSameProcess(agentPid, anchor)) continue; // dead → nothing to reap
|
|
935
|
+
const parentAlive = bgMod.isSameProcess(
|
|
936
|
+
Number(state.pid),
|
|
937
|
+
state.startedAt,
|
|
938
|
+
);
|
|
939
|
+
processes.push({
|
|
940
|
+
pid: agentPid,
|
|
941
|
+
alive: true,
|
|
942
|
+
parentAlive,
|
|
943
|
+
sessionId: state.sessionId || null,
|
|
944
|
+
kind: "agent-child",
|
|
945
|
+
});
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
for (const f of checkOrphanProcesses(processes, activeSessionIds || [])) {
|
|
949
|
+
const killCmd =
|
|
950
|
+
process.platform === "win32"
|
|
951
|
+
? `taskkill /PID ${f.ref} /T /F`
|
|
952
|
+
: `kill ${f.ref}`;
|
|
953
|
+
checks.push(
|
|
954
|
+
check(
|
|
955
|
+
f.id,
|
|
956
|
+
`process pid=${f.ref}`,
|
|
957
|
+
SEVERITY_TO_LEVEL[f.severity] || CHECK_LEVELS.WARN,
|
|
958
|
+
`${f.message} — ${f.remediation}`,
|
|
959
|
+
{
|
|
960
|
+
id: `reap-orphan-${f.ref}`,
|
|
961
|
+
safe: false,
|
|
962
|
+
description: `terminate leaked agent subprocess pid=${f.ref}`,
|
|
963
|
+
command: killCmd,
|
|
964
|
+
},
|
|
965
|
+
),
|
|
966
|
+
);
|
|
967
|
+
}
|
|
968
|
+
} catch (err) {
|
|
969
|
+
checks.push(failedCheck("orphan-process", "orphan agent processes", err));
|
|
970
|
+
}
|
|
971
|
+
|
|
792
972
|
// Duplicate skills — an id defined in more than one layer silently shadows
|
|
793
973
|
// (loadAll keeps only the winner), so a user's custom skill can override, or
|
|
794
974
|
// be overridden by, another without any signal. opts.skillLayerEntries lets a
|