@herbertgao/pi-extensions 2026.8.6 → 2026.8.7
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/README.md +5 -5
- package/node_modules/@herbertgao/pi-cc-extensions/README.en.md +1 -1
- package/node_modules/@herbertgao/pi-cc-extensions/README.md +1 -1
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/config.ts +19 -7
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/panel.ts +30 -9
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/compact-thinking.ts +272 -22
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/compact-mode.ts +94 -77
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/message-display.ts +1 -1
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/hover.ts +14 -0
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/interaction.ts +55 -17
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/layout.ts +41 -2
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-renderer.ts +75 -6
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/types.ts +7 -3
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/grouping.ts +64 -1
- package/node_modules/@herbertgao/pi-cc-extensions/package.json +3 -3
- package/node_modules/@herbertgao/pi-subagents/CHANGELOG.md +6 -0
- package/node_modules/@herbertgao/pi-subagents/README.md +30 -22
- package/node_modules/@herbertgao/pi-subagents/examples/agent-tool-description.md +5 -5
- package/node_modules/@herbertgao/pi-subagents/package.json +7 -7
- package/node_modules/@herbertgao/pi-subagents/src/agent-file-toggle.ts +7 -1
- package/node_modules/@herbertgao/pi-subagents/src/agent-manager.ts +48 -19
- package/node_modules/@herbertgao/pi-subagents/src/agent-runner.ts +17 -13
- package/node_modules/@herbertgao/pi-subagents/src/custom-agents.ts +26 -2
- package/node_modules/@herbertgao/pi-subagents/src/index.ts +219 -40
- package/node_modules/@herbertgao/pi-subagents/src/invocation-config.ts +101 -3
- package/node_modules/@herbertgao/pi-subagents/src/nested-tools.ts +18 -4
- package/node_modules/@herbertgao/pi-subagents/src/settings.ts +99 -0
- package/node_modules/@herbertgao/pi-subagents/src/types.ts +22 -3
- package/node_modules/@herbertgao/pi-subagents/src/ui/agent-widget.ts +51 -3
- package/node_modules/@herbertgao/pi-subagents/src/ui/conversation-viewer.ts +20 -2
- package/node_modules/@herbertgao/pi-subagents/src/ui/fleet-list.ts +21 -6
- package/node_modules/@herbertgao/pi-subagents/src/usage.ts +129 -1
- package/node_modules/@herbertgao/pi-subagents/src/worktree.ts +20 -0
- package/node_modules/@juicesharp/rpiv-ask-user-question/README.md +1 -0
- package/node_modules/@juicesharp/rpiv-ask-user-question/ask-user-question.ts +26 -10
- package/node_modules/@juicesharp/rpiv-ask-user-question/config.ts +25 -0
- package/node_modules/@juicesharp/rpiv-ask-user-question/docs/configuration.md +13 -4
- package/node_modules/@juicesharp/rpiv-ask-user-question/docs/hosts.md +2 -1
- package/node_modules/@juicesharp/rpiv-ask-user-question/docs/localization.md +3 -1
- package/node_modules/@juicesharp/rpiv-ask-user-question/locales/en.json +2 -2
- package/node_modules/@juicesharp/rpiv-ask-user-question/locales/zh.json +2 -2
- package/node_modules/@juicesharp/rpiv-ask-user-question/package.json +3 -2
- package/node_modules/@juicesharp/rpiv-ask-user-question/state/build-questionnaire.ts +10 -0
- package/node_modules/@juicesharp/rpiv-ask-user-question/state/key-router.ts +3 -1
- package/node_modules/@juicesharp/rpiv-ask-user-question/state/questionnaire-session.ts +30 -7
- package/node_modules/@juicesharp/rpiv-ask-user-question/state/state.ts +14 -6
- package/node_modules/@juicesharp/rpiv-ask-user-question/view/dialog-builder.ts +30 -4
- package/node_modules/@juicesharp/rpiv-ask-user-question/view/tab-content-strategy.ts +26 -5
- package/node_modules/@narumitw/pi-btw/README.md +120 -111
- package/node_modules/@narumitw/pi-btw/dist/index.ts +2898 -0
- package/node_modules/@narumitw/pi-btw/dist/index.ts.map +7 -0
- package/node_modules/@narumitw/pi-btw/package.json +10 -6
- package/node_modules/@narumitw/pi-btw/src/btw.ts +36 -40
- package/node_modules/@narumitw/pi-btw/src/fullscreen-ui.ts +55 -10
- package/node_modules/@narumitw/pi-btw/src/main-tree-picker.ts +390 -0
- package/node_modules/@narumitw/pi-btw/src/menu.ts +45 -2
- package/node_modules/@narumitw/pi-btw/src/transcript-pager.ts +136 -45
- package/node_modules/pi-lens/CHANGELOG.md +909 -0
- package/node_modules/pi-lens/README.md +11 -3
- package/node_modules/pi-lens/config/ruff/core.toml +42 -0
- package/node_modules/pi-lens/dist/clients/actionable-warnings.js +130 -27
- package/node_modules/pi-lens/dist/clients/advisory-provenance.js +126 -13
- package/node_modules/pi-lens/dist/clients/agent-nudge.js +67 -11
- package/node_modules/pi-lens/dist/clients/ast-grep-client.js +9 -3
- package/node_modules/pi-lens/dist/clients/atomic-write.js +9 -3
- package/node_modules/pi-lens/dist/clients/bash-file-access.js +285 -8
- package/node_modules/pi-lens/dist/clients/biome-client.js +61 -30
- package/node_modules/pi-lens/dist/clients/blocker-freshness.js +428 -0
- package/node_modules/pi-lens/dist/clients/blocker-past-eof.js +119 -0
- package/node_modules/pi-lens/dist/clients/bootstrap.js +12 -1
- package/node_modules/pi-lens/dist/clients/bounded-pid-file-lock.js +12 -3
- package/node_modules/pi-lens/dist/clients/bounded-telemetry.js +192 -0
- package/node_modules/pi-lens/dist/clients/cascade-budget.js +196 -0
- package/node_modules/pi-lens/dist/clients/cascade-format.js +20 -1
- package/node_modules/pi-lens/dist/clients/cascade-types.js +9 -1
- package/node_modules/pi-lens/dist/clients/child-unref.js +54 -6
- package/node_modules/pi-lens/dist/clients/code-quality-warnings.js +76 -24
- package/node_modules/pi-lens/dist/clients/dead-code-client.js +123 -31
- package/node_modules/pi-lens/dist/clients/deadline-utils.js +8 -1
- package/node_modules/pi-lens/dist/clients/debug-handles.js +5 -0
- package/node_modules/pi-lens/dist/clients/degradation-ledger.js +62 -13
- package/node_modules/pi-lens/dist/clients/dependency-checker.js +25 -3
- package/node_modules/pi-lens/dist/clients/diagnostic-dispositions.js +147 -33
- package/node_modules/pi-lens/dist/clients/diagnostic-line-freshness.js +298 -0
- package/node_modules/pi-lens/dist/clients/dispatch/dispatcher.js +52 -2
- package/node_modules/pi-lens/dist/clients/dispatch/integration.js +256 -88
- package/node_modules/pi-lens/dist/clients/dispatch/lazy.js +7 -4
- package/node_modules/pi-lens/dist/clients/dispatch/plan.js +5 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/ast-grep-napi.js +234 -18
- package/node_modules/pi-lens/dist/clients/dispatch/runners/biome-check.js +187 -19
- package/node_modules/pi-lens/dist/clients/dispatch/runners/cpp-check.js +6 -3
- package/node_modules/pi-lens/dist/clients/dispatch/runners/credo.js +11 -9
- package/node_modules/pi-lens/dist/clients/dispatch/runners/cue-vet.js +398 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/elixir-check.js +11 -3
- package/node_modules/pi-lens/dist/clients/dispatch/runners/eslint.js +12 -7
- package/node_modules/pi-lens/dist/clients/dispatch/runners/go-vet.js +18 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/helm-render.js +895 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/index.js +4 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/javac.js +16 -3
- package/node_modules/pi-lens/dist/clients/dispatch/runners/lsp.js +60 -53
- package/node_modules/pi-lens/dist/clients/dispatch/runners/markdownlint.js +16 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/mypy.js +23 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/psscriptanalyzer.js +331 -55
- package/node_modules/pi-lens/dist/clients/dispatch/runners/pyright.js +38 -5
- package/node_modules/pi-lens/dist/clients/dispatch/runners/rust-clippy.js +97 -19
- package/node_modules/pi-lens/dist/clients/dispatch/runners/spellcheck.js +14 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/sqlfluff.js +20 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/stylelint.js +22 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/swiftlint.js +10 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/taplo.js +18 -6
- package/node_modules/pi-lens/dist/clients/dispatch/runners/trivy-config.js +73 -12
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/availability-policy.js +275 -13
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/candidate-probe.js +21 -6
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/lazy-installer.js +260 -27
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/runner-helpers.js +734 -64
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/spawn-outcome.js +86 -11
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/tool-failure.js +72 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/toolchain-availability.js +9 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/vale.js +9 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/yamllint.js +18 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/zig-check.js +4 -2
- package/node_modules/pi-lens/dist/clients/event-loop-monitor.js +28 -5
- package/node_modules/pi-lens/dist/clients/extension-log.js +22 -1
- package/node_modules/pi-lens/dist/clients/file-kinds.js +8 -2
- package/node_modules/pi-lens/dist/clients/finding-delivery-gate.js +294 -0
- package/node_modules/pi-lens/dist/clients/finding-identity.js +88 -0
- package/node_modules/pi-lens/dist/clients/formatters-lazy.js +3 -2
- package/node_modules/pi-lens/dist/clients/formatters.js +500 -115
- package/node_modules/pi-lens/dist/clients/generation-guard.js +216 -0
- package/node_modules/pi-lens/dist/clients/git-guard.js +31 -0
- package/node_modules/pi-lens/dist/clients/gitleaks-client.js +181 -8
- package/node_modules/pi-lens/dist/clients/govulncheck-client.js +109 -9
- package/node_modules/pi-lens/dist/clients/grammar-source.js +333 -10
- package/node_modules/pi-lens/dist/clients/host-ports.js +8 -0
- package/node_modules/pi-lens/dist/clients/installer/index.js +1114 -100
- package/node_modules/pi-lens/dist/clients/installer/managed-tool-refresh-session.js +58 -0
- package/node_modules/pi-lens/dist/clients/installer/managed-tool-refresh.js +715 -0
- package/node_modules/pi-lens/dist/clients/instance-reaper.js +643 -97
- package/node_modules/pi-lens/dist/clients/instance-registry.js +87 -17
- package/node_modules/pi-lens/dist/clients/jscpd-client.js +82 -6
- package/node_modules/pi-lens/dist/clients/knip-client.js +310 -10
- package/node_modules/pi-lens/dist/clients/language-policy.js +8 -2
- package/node_modules/pi-lens/dist/clients/language-profile.js +2 -0
- package/node_modules/pi-lens/dist/clients/latency-logger.js +440 -11
- package/node_modules/pi-lens/dist/clients/lazy-import.js +21 -0
- package/node_modules/pi-lens/dist/clients/ledger-bounds.js +21 -0
- package/node_modules/pi-lens/dist/clients/lens-flag-registry.js +8 -0
- package/node_modules/pi-lens/dist/clients/lsp/cascade-tier.js +102 -7
- package/node_modules/pi-lens/dist/clients/lsp/client.js +1516 -159
- package/node_modules/pi-lens/dist/clients/lsp/diagnostic-binding.js +83 -1
- package/node_modules/pi-lens/dist/clients/lsp/document-drift.js +389 -0
- package/node_modules/pi-lens/dist/clients/lsp/index.js +2274 -147
- package/node_modules/pi-lens/dist/clients/lsp/inferred-project.js +215 -0
- package/node_modules/pi-lens/dist/clients/lsp/jvm-runtime.js +146 -14
- package/node_modules/pi-lens/dist/clients/lsp/language.js +48 -4
- package/node_modules/pi-lens/dist/clients/lsp/server.js +277 -16
- package/node_modules/pi-lens/dist/clients/lsp/spawn-history.js +22 -0
- package/node_modules/pi-lens/dist/clients/lsp/sync-kind.js +43 -0
- package/node_modules/pi-lens/dist/clients/lsp/tsserver-sync.js +79 -3
- package/node_modules/pi-lens/dist/clients/lsp/wait-policy/strategies.js +43 -0
- package/node_modules/pi-lens/dist/clients/lsp/workspace-diagnostics-cache.js +422 -28
- package/node_modules/pi-lens/dist/clients/lsp/workspace-diagnostics-session.js +30 -0
- package/node_modules/pi-lens/dist/clients/lsp/workspace-sweep-hold.js +175 -0
- package/node_modules/pi-lens/dist/clients/lsp-lazy.js +3 -2
- package/node_modules/pi-lens/dist/clients/mcp/session.js +10 -2
- package/node_modules/pi-lens/dist/clients/module-report.js +8 -0
- package/node_modules/pi-lens/dist/clients/opengrep-client.js +15 -1
- package/node_modules/pi-lens/dist/clients/package-manager.js +170 -14
- package/node_modules/pi-lens/dist/clients/path-attribution-telemetry.js +27 -0
- package/node_modules/pi-lens/dist/clients/path-utils.js +160 -0
- package/node_modules/pi-lens/dist/clients/pipeline.js +154 -33
- package/node_modules/pi-lens/dist/clients/post-autofix-notice.js +37 -0
- package/node_modules/pi-lens/dist/clients/project-diagnostics/cache.js +17 -3
- package/node_modules/pi-lens/dist/clients/project-diagnostics/extractors.js +89 -22
- package/node_modules/pi-lens/dist/clients/project-diagnostics/fresh-fetch.js +84 -14
- package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/gitleaks.js +15 -3
- package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/trivy.js +50 -2
- package/node_modules/pi-lens/dist/clients/project-diagnostics/scanner.js +5 -0
- package/node_modules/pi-lens/dist/clients/project-lens-config.js +3 -0
- package/node_modules/pi-lens/dist/clients/project-snapshot.js +299 -0
- package/node_modules/pi-lens/dist/clients/quiet-window-config.js +47 -0
- package/node_modules/pi-lens/dist/clients/quiet-window.js +11 -25
- package/node_modules/pi-lens/dist/clients/read-guard-logger.js +1 -1
- package/node_modules/pi-lens/dist/clients/read-guard.js +184 -10
- package/node_modules/pi-lens/dist/clients/resource-sampler.js +49 -17
- package/node_modules/pi-lens/dist/clients/reverse-deps.js +50 -11
- package/node_modules/pi-lens/dist/clients/review-graph/query.js +52 -4
- package/node_modules/pi-lens/dist/clients/runtime-agent-end.js +96 -7
- package/node_modules/pi-lens/dist/clients/runtime-context.js +8 -1
- package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +308 -45
- package/node_modules/pi-lens/dist/clients/runtime-session.js +332 -9
- package/node_modules/pi-lens/dist/clients/runtime-tool-call.js +217 -22
- package/node_modules/pi-lens/dist/clients/runtime-tool-result.js +212 -79
- package/node_modules/pi-lens/dist/clients/runtime-turn.js +575 -67
- package/node_modules/pi-lens/dist/clients/safe-spawn.js +220 -7
- package/node_modules/pi-lens/dist/clients/scratch-tree-policy.js +161 -0
- package/node_modules/pi-lens/dist/clients/search-read-registration.js +34 -6
- package/node_modules/pi-lens/dist/clients/security-scan-client.js +157 -24
- package/node_modules/pi-lens/dist/clients/sg-runner.js +116 -22
- package/node_modules/pi-lens/dist/clients/single-flight.js +158 -0
- package/node_modules/pi-lens/dist/clients/stale-marker.js +17 -0
- package/node_modules/pi-lens/dist/clients/startup-timing.js +19 -1
- package/node_modules/pi-lens/dist/clients/test-runner-client.js +151 -16
- package/node_modules/pi-lens/dist/clients/tool-event.js +56 -5
- package/node_modules/pi-lens/dist/clients/tool-policy.js +125 -20
- package/node_modules/pi-lens/dist/clients/tree-sitter-cache.js +75 -1
- package/node_modules/pi-lens/dist/clients/tree-sitter-client.js +769 -64
- package/node_modules/pi-lens/dist/clients/tree-sitter-shared.js +11 -0
- package/node_modules/pi-lens/dist/clients/tree-sitter-symbol-extractor.js +90 -0
- package/node_modules/pi-lens/dist/clients/trivy-client.js +19 -1
- package/node_modules/pi-lens/dist/clients/widget-state.js +336 -36
- package/node_modules/pi-lens/dist/clients/zizmor-config.js +214 -14
- package/node_modules/pi-lens/dist/index.js +67738 -55739
- package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +466 -54
- package/node_modules/pi-lens/dist/tools/lsp-diagnostics.js +132 -43
- package/node_modules/pi-lens/dist/tools/lsp-navigation.js +46 -7
- package/node_modules/pi-lens/docs/agent-guide.md +6 -1
- package/node_modules/pi-lens/docs/ast-grep_rules_catalog.md +515 -506
- package/node_modules/pi-lens/docs/dispositions.md +21 -0
- package/node_modules/pi-lens/docs/features.md +19 -5
- package/node_modules/pi-lens/docs/globalconfig.md +3 -0
- package/node_modules/pi-lens/docs/language-coverage.md +71 -5
- package/node_modules/pi-lens/docs/mcp.md +2 -2
- package/node_modules/pi-lens/docs/servercapabilities.md +4 -1
- package/node_modules/pi-lens/docs/settings.md +19 -1
- package/node_modules/pi-lens/docs/usage.md +1 -0
- package/node_modules/pi-lens/package.json +10 -6
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/__snapshots__/no-reflect-get-js-snapshot.yml +23 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/__snapshots__/no-reflect-get-snapshot.yml +23 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/__snapshots__/ts-void-zero-snapshot.yml +23 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-bare-object-param-test.yml +16 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-chained-type-assertions-test.yml +15 -1
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-compile-call-test.yml +29 -1
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-dupe-class-members-test.yml +33 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-inner-html-js-test.yml +7 -1
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-inner-html-test.yml +7 -1
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-known-value-widening-test.yml +9 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-non-null-assertion-test.yml +216 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-apply-js-test.yml +2 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-apply-test.yml +2 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-get-js-test.yml +2 -1
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-get-test.yml +2 -1
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-return-value-in-generator-test.yml +12 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-runtime-typeof-test.yml +33 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-server-bind-wildcard-test.yml +9 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-shape-in-symbol-names-test.yml +13 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-sql-in-code-js-test.yml +5 -1
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-sql-in-code-test.yml +5 -1
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-unknown-parameters-test.yml +12 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-unknown-returns-test.yml +16 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-unsafe-dictionary-any-test.yml +10 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-unsafe-dictionary-unknown-test.yml +8 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/redundant-unsafe-function-test.yml +35 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/require-safety-comment-for-as-unknown-as-test.yml +177 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/ts-void-zero-test.yml +1 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-await-expression-member-js.yml +13 -2
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-await-expression-member.yml +13 -2
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-bare-host-path-in-win32-branch.yml +2 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-bare-object-param.yml +60 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-chained-type-assertions.yml +57 -12
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-compile-call.yml +63 -5
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-dupe-class-members.yml +52 -8
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-inner-html-js.yml +27 -2
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-inner-html.yml +27 -2
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-known-value-widening.yml +71 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-non-null-assertion.yml +316 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-raw-json-store-write.yml +7 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-apply-js.yml +22 -2
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-apply.yml +23 -2
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-get-js.yml +33 -5
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-get.yml +34 -8
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-return-value-in-generator.yml +22 -5
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-runtime-typeof.yml +121 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-server-bind-wildcard.yml +23 -7
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-shape-in-symbol-names.yml +68 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-sql-in-code-js.yml +41 -2
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-sql-in-code.yml +41 -2
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-unknown-laundering.yml +6 -1
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-unknown-parameters.yml +67 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-unknown-returns.yml +106 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-unsafe-dictionary-any.yml +50 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-unsafe-dictionary-unknown.yml +56 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-win32-isabsolute-for-qualification.yml +2 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/redundant-unsafe-function.yml +32 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/require-safety-comment-for-as-unknown-as.yml +184 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/ts-void-zero.yml +5 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/self-scan-baseline.json +5 -0
- package/node_modules/pi-lens/scripts/analyze-pi-lens-logs.mjs +53 -1
- package/node_modules/pi-lens/scripts/download-grammars.js +25 -0
- package/node_modules/pi-lens/scripts/grammars.lock.json +9 -0
- package/node_modules/pi-lens/skills/pi-lens-ast-grep/SKILL.md +2 -0
- package/node_modules/pi-lens/skills/pi-lens-write-ast-grep-rule/SKILL.md +80 -3
- package/node_modules/pi-lens/skills/pi-lens-write-ast-grep-rule/reference.md +41 -0
- package/node_modules/pi-lens/vendor/grammars/tree-sitter-cue.wasm +0 -0
- package/node_modules/pi-mcp-adapter/CHANGELOG.md +36 -0
- package/node_modules/pi-mcp-adapter/README.md +47 -5
- package/node_modules/pi-mcp-adapter/cli.js +163 -1
- package/node_modules/pi-mcp-adapter/commands.ts +128 -22
- package/node_modules/pi-mcp-adapter/config.ts +25 -7
- package/node_modules/pi-mcp-adapter/direct-tools.ts +5 -4
- package/node_modules/pi-mcp-adapter/index.ts +219 -32
- package/node_modules/pi-mcp-adapter/init.ts +36 -4
- package/node_modules/pi-mcp-adapter/lifecycle.ts +315 -24
- package/node_modules/pi-mcp-adapter/mcp-auth-flow.ts +39 -6
- package/node_modules/pi-mcp-adapter/mcp-bearer-store.ts +387 -0
- package/node_modules/pi-mcp-adapter/mcp-callback-server.ts +27 -3
- package/node_modules/pi-mcp-adapter/mcp-code.ts +4 -0
- package/node_modules/pi-mcp-adapter/mcp-panel.ts +42 -13
- package/node_modules/pi-mcp-adapter/metadata-cache.ts +32 -21
- package/node_modules/pi-mcp-adapter/package-mcp-loader.ts +168 -0
- package/node_modules/pi-mcp-adapter/package.json +3 -1
- package/node_modules/pi-mcp-adapter/proxy-modes.ts +5 -4
- package/node_modules/pi-mcp-adapter/request-headers-command.ts +31 -37
- package/node_modules/pi-mcp-adapter/search-ranking.ts +92 -22
- package/node_modules/pi-mcp-adapter/server-manager.ts +140 -2
- package/node_modules/pi-mcp-adapter/session-recovery.ts +7 -0
- package/node_modules/pi-mcp-adapter/tool-approval.ts +15 -2
- package/node_modules/pi-mcp-adapter/tool-result-renderer.ts +44 -4
- package/node_modules/pi-mcp-adapter/types.ts +2 -0
- package/node_modules/pi-mcp-adapter/ui-server.ts +18 -16
- package/node_modules/pi-mcp-adapter/utils.ts +70 -0
- package/node_modules/pi-web-access/CHANGELOG.md +23 -0
- package/node_modules/pi-web-access/README.md +27 -9
- package/node_modules/pi-web-access/anysearch.ts +4 -2
- package/node_modules/pi-web-access/brave.ts +16 -4
- package/node_modules/pi-web-access/curator-page.ts +6 -3
- package/node_modules/pi-web-access/curator-server.ts +4 -1
- package/node_modules/pi-web-access/exa.ts +18 -7
- package/node_modules/pi-web-access/extract.ts +28 -3
- package/node_modules/pi-web-access/gemini-search.ts +16 -4
- package/node_modules/pi-web-access/index.ts +41 -57
- package/node_modules/pi-web-access/package.json +1 -1
- package/node_modules/pi-web-access/page-query.ts +4 -2
- package/node_modules/pi-web-access/parallel-mcp.ts +249 -0
- package/node_modules/pi-web-access/parallel.ts +2 -2
- package/node_modules/pi-web-access/query-rewrite.ts +51 -0
- package/node_modules/pi-web-access/serper.ts +211 -0
- package/node_modules/pi-web-access/summary-model-scope.ts +12 -4
- package/node_modules/pi-web-access/summary-review.ts +44 -22
- package/node_modules/pi-web-access/tavily.ts +16 -4
- package/node_modules/pi-web-access/utils.ts +87 -0
- package/node_modules/pi-web-access/valyu.ts +199 -0
- package/package.json +13 -13
|
@@ -66,7 +66,9 @@ import * as fs from "node:fs";
|
|
|
66
66
|
import * as path from "node:path";
|
|
67
67
|
import { writeFileAtomicAsync } from "./atomic-write.js";
|
|
68
68
|
import { sweepOwnStagingFiles, } from "./atomic-write-staging.js";
|
|
69
|
-
import {
|
|
69
|
+
import { acquireQuarantinePidFileLock } from "./bounded-pid-file-lock.js";
|
|
70
|
+
import { spawnCollectStdoutResult, unrefChildAndPipes, } from "./child-unref.js";
|
|
71
|
+
import { incrementDegradationCount, recordDegradationOnce, } from "./degradation-ledger.js";
|
|
70
72
|
import { getGlobalPiLensDir } from "./file-utils.js";
|
|
71
73
|
import { isInstanceRegistryEnabled, readInstanceRegistry, } from "./instance-registry.js";
|
|
72
74
|
import { logLatency } from "./latency-logger.js";
|
|
@@ -267,8 +269,16 @@ function posixPsPath() {
|
|
|
267
269
|
function escapeWqlLikeValue(value) {
|
|
268
270
|
return value.replaceAll("'", "''").replaceAll(/[%_]/g, (ch) => `[${ch}]`);
|
|
269
271
|
}
|
|
272
|
+
/** Escape a value for a WQL EQUALITY comparison: only the string delimiter
|
|
273
|
+
* needs escaping — `%`/`_` are literal outside `LIKE`. */
|
|
274
|
+
function escapeWqlStringValue(value) {
|
|
275
|
+
return value.replaceAll("'", "''");
|
|
276
|
+
}
|
|
270
277
|
/** Search running processes whose command line contains `marker` (Windows,
|
|
271
|
-
* via CIM/WQL). Returns matching pids. Best-effort: any failure ⇒ []
|
|
278
|
+
* via CIM/WQL). Returns matching pids. Best-effort: any failure ⇒ [], but a
|
|
279
|
+
* failure is RECORDED (#1857 class sweep) — "the search found no orphan" and
|
|
280
|
+
* "the search never ran" produce the same empty array, and only the record
|
|
281
|
+
* tells them apart. */
|
|
272
282
|
async function findPidsByMarkerWindows(marker) {
|
|
273
283
|
if (!isWindows || !marker)
|
|
274
284
|
return [];
|
|
@@ -279,8 +289,15 @@ async function findPidsByMarkerWindows(marker) {
|
|
|
279
289
|
`| Where-Object { $_.ProcessId -ne $PID } ` +
|
|
280
290
|
`| Select-Object -ExpandProperty ProcessId`;
|
|
281
291
|
const powershell = windowsExe("WindowsPowerShell\\v1.0\\powershell.exe");
|
|
282
|
-
const
|
|
283
|
-
|
|
292
|
+
const result = await spawnCollectStdoutResult(powershell, ["-NoProfile", "-NonInteractive", "-Command", psScript], { shell: false, windowsHide: true, stdio: ["ignore", "pipe", "ignore"] }, { timeoutMs: BACKSTOP_SCAN_TIMEOUT_MS });
|
|
293
|
+
if (result.status !== "ok") {
|
|
294
|
+
recordDegradationOnce({
|
|
295
|
+
kind: "orphan-backstop-scan-failed",
|
|
296
|
+
subject: "marker-search",
|
|
297
|
+
reason: `marker command-line search ${result.status}`,
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
return result.stdout
|
|
284
301
|
.split(/\r?\n/)
|
|
285
302
|
.map((line) => Number(line.trim()))
|
|
286
303
|
.filter((n) => Number.isFinite(n) && n > 0);
|
|
@@ -288,19 +305,35 @@ async function findPidsByMarkerWindows(marker) {
|
|
|
288
305
|
/** Fetch command lines for a set of pids in one query (Windows: CIM; POSIX:
|
|
289
306
|
* `ps`). Returns a pid → command-line map; pids that can't be resolved are
|
|
290
307
|
* simply absent (the caller treats absent as "identity unverifiable — do not
|
|
291
|
-
* kill by pid").
|
|
308
|
+
* kill by pid").
|
|
309
|
+
*
|
|
310
|
+
* Best-effort: any failure ⇒ empty map, RECORDED (#1857 class sweep). An
|
|
311
|
+
* errored query makes every recorded child unverifiable at once, which
|
|
312
|
+
* suppresses the entire registry-driven reap — and reads exactly like a
|
|
313
|
+
* registry whose children have all already exited. Only the record separates
|
|
314
|
+
* the two. */
|
|
292
315
|
async function queryCommandLines(pids) {
|
|
293
316
|
const valid = [...new Set(pids.filter((p) => Number.isFinite(p) && p > 0))];
|
|
294
317
|
const map = new Map();
|
|
295
318
|
if (valid.length === 0)
|
|
296
319
|
return map;
|
|
320
|
+
const noteFailure = (status) => {
|
|
321
|
+
if (status === "ok")
|
|
322
|
+
return;
|
|
323
|
+
recordDegradationOnce({
|
|
324
|
+
kind: "orphan-backstop-scan-failed",
|
|
325
|
+
subject: "identity-query",
|
|
326
|
+
reason: `command-line identity query ${status}; reap suppressed this sweep`,
|
|
327
|
+
});
|
|
328
|
+
};
|
|
297
329
|
if (isWindows) {
|
|
298
330
|
const filter = valid.map((p) => `ProcessId=${p}`).join(" OR ");
|
|
299
331
|
const psScript = `Get-CimInstance Win32_Process -Filter "${filter}" ` +
|
|
300
332
|
`| ForEach-Object { "$($_.ProcessId)\t$($_.CommandLine)" }`;
|
|
301
333
|
const powershell = windowsExe("WindowsPowerShell\\v1.0\\powershell.exe");
|
|
302
|
-
const
|
|
303
|
-
|
|
334
|
+
const result = await spawnCollectStdoutResult(powershell, ["-NoProfile", "-NonInteractive", "-Command", psScript], { shell: false, windowsHide: true, stdio: ["ignore", "pipe", "ignore"] }, { timeoutMs: BACKSTOP_SCAN_TIMEOUT_MS });
|
|
335
|
+
noteFailure(result.status);
|
|
336
|
+
for (const line of result.stdout.split(/\r?\n/)) {
|
|
304
337
|
const tab = line.indexOf("\t");
|
|
305
338
|
if (tab <= 0)
|
|
306
339
|
continue;
|
|
@@ -310,8 +343,14 @@ async function queryCommandLines(pids) {
|
|
|
310
343
|
}
|
|
311
344
|
return map;
|
|
312
345
|
}
|
|
313
|
-
const
|
|
314
|
-
|
|
346
|
+
const result = await spawnCollectStdoutResult(posixPsPath(), ["-p", valid.join(","), "-o", "pid=,args="], { shell: false, stdio: ["ignore", "pipe", "ignore"] }, { timeoutMs: BACKSTOP_SCAN_TIMEOUT_MS });
|
|
347
|
+
// `ps -p` exits nonzero when NONE of the pids exist, which is a legitimate
|
|
348
|
+
// clean result here, so only spawn/timeout failures are recorded. The
|
|
349
|
+
// shared collector calls that an exit failure, but this caller's command
|
|
350
|
+
// contract makes that status the expected empty-table result.
|
|
351
|
+
if (result.status !== "exit-error")
|
|
352
|
+
noteFailure(result.status);
|
|
353
|
+
for (const line of result.stdout.split(/\r?\n/)) {
|
|
315
354
|
// Linear parse (S8786/S6594: avoid regex backtracking on
|
|
316
355
|
// attacker-lengthenable ps output) — trim leading whitespace,
|
|
317
356
|
// then split on the first whitespace run: " 1234 args here".
|
|
@@ -359,12 +398,34 @@ export function buildIdentityMatcher(cmdlines) {
|
|
|
359
398
|
return cmdline.toLowerCase().includes(basename);
|
|
360
399
|
};
|
|
361
400
|
}
|
|
362
|
-
/**
|
|
363
|
-
*
|
|
364
|
-
*
|
|
365
|
-
|
|
401
|
+
/** Post-kill liveness poll budget. `taskkill /F /T` returns before the kernel
|
|
402
|
+
* has finished tearing the tree down, so a single immediate check would
|
|
403
|
+
* report false `alive`s. */
|
|
404
|
+
export const KILL_VERIFY_ATTEMPTS = 5;
|
|
405
|
+
export const KILL_VERIFY_INTERVAL_MS = 100;
|
|
406
|
+
/** `setTimeout` as an awaitable, with the timer unref'd so a settled one-shot
|
|
407
|
+
* `pi --print` never waits on best-effort kill verification. */
|
|
408
|
+
function sleepUnref(ms) {
|
|
409
|
+
return new Promise((resolve) => {
|
|
410
|
+
const timer = setTimeout(resolve, ms);
|
|
411
|
+
timer.unref();
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Force-kill a pid's full process tree, then VERIFY. Windows: `taskkill /F
|
|
416
|
+
* /T`. POSIX: mirror killProcessTree's process-group kill, falling back to a
|
|
417
|
+
* direct signal. The kill itself stays best-effort (all errors swallowed) —
|
|
418
|
+
* what changed in #1857 is that the caller now learns the outcome, so an
|
|
419
|
+
* unverified kill can be recorded with identity instead of being counted as
|
|
420
|
+
* a success.
|
|
421
|
+
*
|
|
422
|
+
* Verification direction is conservative in the safe direction: a recycled
|
|
423
|
+
* pid now belonging to an unrelated live process reports `alive`, so a real
|
|
424
|
+
* reap can be under-reported, but a failed reap is never reported as done.
|
|
425
|
+
*/
|
|
426
|
+
async function killPidTree(pid, options = {}) {
|
|
366
427
|
if (!Number.isFinite(pid) || pid <= 0)
|
|
367
|
-
return;
|
|
428
|
+
return "invalid";
|
|
368
429
|
if (isWindows) {
|
|
369
430
|
try {
|
|
370
431
|
const taskkill = windowsExe("taskkill.exe");
|
|
@@ -382,19 +443,42 @@ async function killPidTree(pid) {
|
|
|
382
443
|
catch {
|
|
383
444
|
// best-effort
|
|
384
445
|
}
|
|
385
|
-
return;
|
|
386
446
|
}
|
|
387
|
-
|
|
388
|
-
|
|
447
|
+
else {
|
|
448
|
+
try {
|
|
449
|
+
process.kill(-pid, "SIGKILL");
|
|
450
|
+
}
|
|
451
|
+
catch {
|
|
452
|
+
try {
|
|
453
|
+
process.kill(pid, "SIGKILL");
|
|
454
|
+
}
|
|
455
|
+
catch {
|
|
456
|
+
// best-effort — process may already be gone
|
|
457
|
+
}
|
|
458
|
+
}
|
|
389
459
|
}
|
|
390
|
-
|
|
460
|
+
return await verifyPidGone(pid, options);
|
|
461
|
+
}
|
|
462
|
+
/** Poll `isPidAlive` for a short budget after a kill attempt. */
|
|
463
|
+
async function verifyPidGone(pid, options = {}) {
|
|
464
|
+
const isPidAlive = options.isPidAlive ?? realIsPidAlive;
|
|
465
|
+
const attempts = Math.max(1, options.verifyAttempts ?? KILL_VERIFY_ATTEMPTS);
|
|
466
|
+
const intervalMs = Math.max(0, options.verifyIntervalMs ?? KILL_VERIFY_INTERVAL_MS);
|
|
467
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
468
|
+
let alive;
|
|
391
469
|
try {
|
|
392
|
-
|
|
470
|
+
alive = isPidAlive(pid);
|
|
393
471
|
}
|
|
394
472
|
catch {
|
|
395
|
-
//
|
|
473
|
+
// An unreadable liveness probe is not evidence of death.
|
|
474
|
+
alive = true;
|
|
396
475
|
}
|
|
476
|
+
if (!alive)
|
|
477
|
+
return "gone";
|
|
478
|
+
if (attempt < attempts - 1)
|
|
479
|
+
await sleepUnref(intervalMs);
|
|
397
480
|
}
|
|
481
|
+
return "alive";
|
|
398
482
|
}
|
|
399
483
|
/**
|
|
400
484
|
* #658: known pi-lens-managed LSP/scanner binary names (clients/lsp/server.ts
|
|
@@ -406,15 +490,29 @@ async function killPidTree(pid) {
|
|
|
406
490
|
* (script path), not the process image name, exactly like the existing
|
|
407
491
|
* marker-search's WQL LIKE query below.
|
|
408
492
|
*/
|
|
409
|
-
export const
|
|
410
|
-
"ast-grep",
|
|
411
|
-
"opengrep-core",
|
|
412
|
-
"opengrep",
|
|
413
|
-
"marksman",
|
|
414
|
-
"zizmor",
|
|
415
|
-
"typos-lsp",
|
|
416
|
-
"yaml-language-server",
|
|
417
|
-
"typescript-language-server",
|
|
493
|
+
export const MANAGED_BINARIES = [
|
|
494
|
+
{ name: "ast-grep", launcher: "native" },
|
|
495
|
+
{ name: "opengrep-core", launcher: "native" },
|
|
496
|
+
{ name: "opengrep", launcher: "native" },
|
|
497
|
+
{ name: "marksman", launcher: "native" },
|
|
498
|
+
{ name: "zizmor", launcher: "native" },
|
|
499
|
+
{ name: "typos-lsp", launcher: "native" },
|
|
500
|
+
{ name: "yaml-language-server", launcher: "node" },
|
|
501
|
+
{ name: "typescript-language-server", launcher: "node" },
|
|
502
|
+
];
|
|
503
|
+
/** Derived, never hand-written: the command-line substrings every platform
|
|
504
|
+
* matches against. */
|
|
505
|
+
export const MANAGED_BINARY_NAMES = MANAGED_BINARIES.map((binary) => binary.name);
|
|
506
|
+
/**
|
|
507
|
+
* Derived Windows `Win32_Process.Name` values for the enumeration pre-filter
|
|
508
|
+
* (#1857). Equality on `Name` replaces eight leading-wildcard `CommandLine
|
|
509
|
+
* LIKE '%…%'` clauses; the node-launched entries collapse to `node.exe`, so
|
|
510
|
+
* the query returns a SUPERSET that JS then narrows by the same
|
|
511
|
+
* `MANAGED_BINARY_NAMES` substring test POSIX already uses. Identical
|
|
512
|
+
* semantics, one filtering rule.
|
|
513
|
+
*/
|
|
514
|
+
export const MANAGED_IMAGE_NAMES = [
|
|
515
|
+
...new Set(MANAGED_BINARIES.map((binary) => binary.launcher === "node" ? "node.exe" : `${binary.name}.exe`)),
|
|
418
516
|
];
|
|
419
517
|
/**
|
|
420
518
|
* PURE decision function for the #658 registry-independent backstop: given a
|
|
@@ -441,13 +539,44 @@ export const MANAGED_BINARY_NAMES = [
|
|
|
441
539
|
* matching only decides which processes are candidates for this check at
|
|
442
540
|
* all); a live parent is never overridden "however unfamiliar" the process.
|
|
443
541
|
*/
|
|
444
|
-
export function decideBackstopOrphanReaping(processes, registry, isPidAlive) {
|
|
542
|
+
export function decideBackstopOrphanReaping(processes, registry, isPidAlive, options = {}) {
|
|
543
|
+
return partitionBackstopCandidates(processes, registry, isPidAlive, options)
|
|
544
|
+
.eligible;
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* Minimum process age before a name-matching, dead-parent process becomes
|
|
548
|
+
* backstop-kill-eligible (#1857 item 4).
|
|
549
|
+
*
|
|
550
|
+
* The hazard: LSP servers launch through `.cmd` shims with `shell: true`, so
|
|
551
|
+
* the pid registered in `lspChildren[]` is the shim's. Between the real
|
|
552
|
+
* server's spawn and its registration there is a window in which the server
|
|
553
|
+
* is name-matching and untracked. The 2026-08-20 session measured that window
|
|
554
|
+
* at ~890ms (spawn 10:54:00.602, registration 10:54:01.492) with a sweep
|
|
555
|
+
* ending at 10:53:59.996 — a near miss saved only by the parent-alive guard,
|
|
556
|
+
* which is a guard about the PARENT and therefore does not cover a shim whose
|
|
557
|
+
* parent is briefly unresolvable.
|
|
558
|
+
*
|
|
559
|
+
* 60s is roughly 67x the observed window and costs nothing against real
|
|
560
|
+
* orphans, which are minutes-to-hours old by the time any sweep sees them.
|
|
561
|
+
*/
|
|
562
|
+
export const BACKSTOP_SPAWN_GRACE_MS = 60_000;
|
|
563
|
+
/**
|
|
564
|
+
* The single implementation of the backstop decision. Returns the eligible
|
|
565
|
+
* set plus the two age-rejected sets, so `sweepUntrackedOrphans` can record
|
|
566
|
+
* WHY a candidate was spared instead of losing it into an unexplained count.
|
|
567
|
+
*/
|
|
568
|
+
export function partitionBackstopCandidates(processes, registry, isPidAlive, options = {}) {
|
|
569
|
+
const graceMs = Math.max(0, options.graceMs ?? BACKSTOP_SPAWN_GRACE_MS);
|
|
445
570
|
const trackedPids = new Set();
|
|
446
571
|
for (const instance of registry) {
|
|
447
572
|
for (const child of instance.lspChildren)
|
|
448
573
|
trackedPids.add(child.pid);
|
|
449
574
|
}
|
|
450
|
-
const
|
|
575
|
+
const partition = {
|
|
576
|
+
eligible: [],
|
|
577
|
+
tooFresh: [],
|
|
578
|
+
unknownAge: [],
|
|
579
|
+
};
|
|
451
580
|
for (const proc of processes) {
|
|
452
581
|
if (trackedPids.has(proc.pid))
|
|
453
582
|
continue; // owned by the registry-driven reaper
|
|
@@ -457,45 +586,84 @@ export function decideBackstopOrphanReaping(processes, registry, isPidAlive) {
|
|
|
457
586
|
continue; // malformed data guard
|
|
458
587
|
if (isPidAlive(proc.parentPid))
|
|
459
588
|
continue; // live parent — never kill
|
|
460
|
-
|
|
589
|
+
// Spawn-grace guard, applied LAST so the buckets only ever contain
|
|
590
|
+
// processes that passed every other eligibility test.
|
|
591
|
+
if (graceMs > 0) {
|
|
592
|
+
const ageMs = proc.ageMs;
|
|
593
|
+
if (typeof ageMs !== "number" || !Number.isFinite(ageMs)) {
|
|
594
|
+
partition.unknownAge.push(proc);
|
|
595
|
+
continue;
|
|
596
|
+
}
|
|
597
|
+
if (ageMs < graceMs) {
|
|
598
|
+
partition.tooFresh.push(proc);
|
|
599
|
+
continue;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
partition.eligible.push(proc);
|
|
461
603
|
}
|
|
462
|
-
return
|
|
604
|
+
return partition;
|
|
463
605
|
}
|
|
464
606
|
/** Enumerate live OS processes whose command line contains one of
|
|
465
607
|
* `MANAGED_BINARY_NAMES` (#658), independent of the instance registry.
|
|
466
608
|
* Windows: one batched CIM/WQL query (mirrors `findPidsByMarkerWindows`'s
|
|
467
609
|
* query pattern). POSIX: `ps -eo pid=,ppid=,args=`, filtered in JS. Returns
|
|
468
610
|
* `{pid, parentPid, command}` rows. Best-effort: any failure ⇒ []. */
|
|
469
|
-
async function enumerateManagedProcesses() {
|
|
611
|
+
async function enumerateManagedProcesses(options = {}) {
|
|
612
|
+
const timeoutMs = options.timeoutMs ?? BACKSTOP_SCAN_TIMEOUT_MS;
|
|
613
|
+
const collect = {
|
|
614
|
+
timeoutMs,
|
|
615
|
+
onTimeout: (child) => terminateScannerChild(child, options),
|
|
616
|
+
};
|
|
470
617
|
if (isWindows) {
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
618
|
+
// #1857: `Name = '…'` equality replaces eight leading-wildcard
|
|
619
|
+
// `CommandLine LIKE '%…%'` clauses, and the query now also projects
|
|
620
|
+
// `CreationDate` so the spawn-grace guard has an age to work with. The
|
|
621
|
+
// image-name set is DERIVED from MANAGED_BINARIES (MANAGED_IMAGE_NAMES),
|
|
622
|
+
// so adding a managed binary cannot leave a stale parallel list behind.
|
|
623
|
+
const clauses = MANAGED_IMAGE_NAMES.map((name) => `Name = '${escapeWqlStringValue(name)}'`).join(" OR ");
|
|
624
|
+
// The age column is computed IN PowerShell as a millisecond delta, not
|
|
625
|
+
// exported as `CreationDate.Ticks`. `Ticks` is the LOCAL-time
|
|
626
|
+
// representation, so subtracting the Unix epoch in JS produced an age
|
|
627
|
+
// wrong by the machine's UTC offset — measured on a UTC+3 host as
|
|
628
|
+
// -8358s for a process started 40 minutes earlier. A delta computed on
|
|
629
|
+
// one side of the boundary has no timezone to get wrong. The explicit
|
|
630
|
+
// `[datetime]` test matters: `(Get-Date) - $null` yields a two-thousand
|
|
631
|
+
// year TimeSpan, which would read as "ancient" and defeat the grace
|
|
632
|
+
// guard exactly where the data is missing.
|
|
633
|
+
const psScript = `Get-CimInstance -Query "SELECT ProcessId,ParentProcessId,CreationDate,CommandLine FROM Win32_Process WHERE ${clauses}" ` +
|
|
634
|
+
`| ForEach-Object { $age = if ($_.CreationDate -is [datetime]) { [int64]((Get-Date) - $_.CreationDate).TotalMilliseconds } else { '' }; ` +
|
|
635
|
+
`"$($_.ProcessId)\t$($_.ParentProcessId)\t$age\t$($_.CommandLine)" }`;
|
|
474
636
|
const powershell = windowsExe("WindowsPowerShell\\v1.0\\powershell.exe");
|
|
475
|
-
const
|
|
476
|
-
const
|
|
477
|
-
for (const line of
|
|
637
|
+
const result = await spawnCollectStdoutResult(powershell, ["-NoProfile", "-NonInteractive", "-Command", psScript], { shell: false, windowsHide: true, stdio: ["ignore", "pipe", "ignore"] }, collect);
|
|
638
|
+
const processes = [];
|
|
639
|
+
for (const line of result.stdout.split(/\r?\n/)) {
|
|
478
640
|
const firstTab = line.indexOf("\t");
|
|
479
641
|
if (firstTab <= 0)
|
|
480
642
|
continue;
|
|
481
643
|
const secondTab = line.indexOf("\t", firstTab + 1);
|
|
482
644
|
if (secondTab <= 0)
|
|
483
645
|
continue;
|
|
646
|
+
const thirdTab = line.indexOf("\t", secondTab + 1);
|
|
647
|
+
if (thirdTab <= 0)
|
|
648
|
+
continue;
|
|
484
649
|
const pid = Number(line.slice(0, firstTab).trim());
|
|
485
650
|
const parentPid = Number(line.slice(firstTab + 1, secondTab).trim());
|
|
486
|
-
const
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
651
|
+
const ageMs = parseNonNegativeMs(line.slice(secondTab + 1, thirdTab));
|
|
652
|
+
const command = line.slice(thirdTab + 1);
|
|
653
|
+
if (!Number.isFinite(pid) || pid <= 0)
|
|
654
|
+
continue;
|
|
655
|
+
processes.push({ pid, parentPid, command, ageMs });
|
|
490
656
|
}
|
|
491
|
-
return
|
|
657
|
+
return narrowToManagedBinaries(processes, result);
|
|
492
658
|
}
|
|
493
659
|
// POSIX: enumerate everything, filter in JS by managed-name substring —
|
|
494
660
|
// there is no single-query WQL-style server-side filter available.
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
661
|
+
// `etime` is the POSIX-standard elapsed-time column (Linux and macOS both
|
|
662
|
+
// support it; `etimes` is Linux-only), and supplies the spawn-grace age.
|
|
663
|
+
const result = await spawnCollectStdoutResult(posixPsPath(), [...POSIX_PS_ARGS], { shell: false, stdio: ["ignore", "pipe", "ignore"] }, collect);
|
|
664
|
+
const processes = [];
|
|
665
|
+
for (const line of result.stdout.split(/\r?\n/)) {
|
|
666
|
+
// Linear parse (S8786/S6594): " pid ppid etime args here".
|
|
499
667
|
const trimmed = line.trimStart();
|
|
500
668
|
if (!trimmed)
|
|
501
669
|
continue;
|
|
@@ -516,18 +684,118 @@ async function enumerateManagedProcesses() {
|
|
|
516
684
|
if (j === 0)
|
|
517
685
|
continue;
|
|
518
686
|
const parentPid = Number(rest.slice(0, j));
|
|
519
|
-
|
|
687
|
+
rest = rest.slice(j);
|
|
688
|
+
let m = 0;
|
|
520
689
|
while (m < rest.length && (rest[m] === " " || rest[m] === "\t"))
|
|
521
690
|
m++;
|
|
522
|
-
|
|
691
|
+
rest = rest.slice(m);
|
|
692
|
+
// etime token: a non-whitespace run of digits, ':' and '-'.
|
|
693
|
+
let e = 0;
|
|
694
|
+
while (e < rest.length && rest[e] !== " " && rest[e] !== "\t")
|
|
695
|
+
e++;
|
|
696
|
+
const ageMs = ageMsFromPosixEtime(rest.slice(0, e));
|
|
697
|
+
let n = e;
|
|
698
|
+
while (n < rest.length && (rest[n] === " " || rest[n] === "\t"))
|
|
699
|
+
n++;
|
|
700
|
+
const args = rest.slice(n);
|
|
523
701
|
if (!Number.isFinite(pid) || pid <= 0)
|
|
524
702
|
continue;
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
703
|
+
processes.push({ pid, parentPid, command: args, ageMs });
|
|
704
|
+
}
|
|
705
|
+
return narrowToManagedBinaries(processes, result);
|
|
706
|
+
}
|
|
707
|
+
/**
|
|
708
|
+
* The ONE place a raw OS-process snapshot is narrowed to managed binaries.
|
|
709
|
+
* Both platforms over-collect for their own reason — Windows because
|
|
710
|
+
* `node.exe` is a superset image name covering every node process on the
|
|
711
|
+
* machine, POSIX because `ps -e` has no server-side filter — so the narrowing
|
|
712
|
+
* lives at a single call site rather than being repeated per branch, where one
|
|
713
|
+
* copy could rot or be dropped unnoticed.
|
|
714
|
+
*/
|
|
715
|
+
function narrowToManagedBinaries(processes, result) {
|
|
716
|
+
return {
|
|
717
|
+
processes: processes.filter((proc) => matchesManagedBinary(proc.command)),
|
|
718
|
+
status: result.status,
|
|
719
|
+
timeoutKill: result.timeoutKill,
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
/**
|
|
723
|
+
* Terminate a scanner child that blew the scan timeout, using the reaper's
|
|
724
|
+
* OWN tree-kill-and-verify machinery (#1864 review F3).
|
|
725
|
+
*
|
|
726
|
+
* The previous handler sent one bare signal to the direct child and resolved
|
|
727
|
+
* in the same tick. On Windows that signal reaches only the `powershell.exe`
|
|
728
|
+
* we spawned, never a CIM worker beneath it, and nothing checked whether it
|
|
729
|
+
* died. An orphan sweep that leaks its own scanner is the defect the sweep
|
|
730
|
+
* exists to fix, so the scanner now gets exactly what an orphan gets:
|
|
731
|
+
* `taskkill /F /T` or a POSIX group kill, then a verified liveness poll. The
|
|
732
|
+
* escalation is recorded with the scanner's identity, never swallowed.
|
|
733
|
+
*/
|
|
734
|
+
export async function terminateScannerChild(child, options) {
|
|
735
|
+
const pid = child.pid;
|
|
736
|
+
const outcome = typeof pid === "number" && pid > 0
|
|
737
|
+
? await killPidTree(pid, {
|
|
738
|
+
verifyAttempts: options.verifyAttempts,
|
|
739
|
+
verifyIntervalMs: options.verifyIntervalMs,
|
|
740
|
+
})
|
|
741
|
+
: "invalid";
|
|
742
|
+
incrementDegradationCount({
|
|
743
|
+
kind: "orphan-backstop-scanner-escalated",
|
|
744
|
+
subject: `${isWindows ? "win32-cim" : "posix-ps"}#${pid ?? "no-pid"}`,
|
|
745
|
+
reason: `scan exceeded its timeout; tree kill reported ${outcome}`,
|
|
746
|
+
});
|
|
747
|
+
return outcome;
|
|
748
|
+
}
|
|
749
|
+
/** Hard bound on the enumeration child. The 2026-08-20 incident measured the
|
|
750
|
+
* Windows CIM query at 9344ms under session_start contention; this caps the
|
|
751
|
+
* worst case rather than trusting the median. */
|
|
752
|
+
export const BACKSTOP_SCAN_TIMEOUT_MS = 5_000;
|
|
753
|
+
/** The one command-line match rule, shared by both platforms' parsers. */
|
|
754
|
+
function matchesManagedBinary(command) {
|
|
755
|
+
const lower = command.toLowerCase();
|
|
756
|
+
return MANAGED_BINARY_NAMES.some((name) => lower.includes(name.toLowerCase()));
|
|
757
|
+
}
|
|
758
|
+
/**
|
|
759
|
+
* Parse the Windows age column: a non-negative integer millisecond count, or
|
|
760
|
+
* anything else. A negative or malformed value is `undefined`, not zero and
|
|
761
|
+
* not a clamp — a nonsensical age means the age is UNKNOWN, and the grace
|
|
762
|
+
* guard must spare an unknown-age process rather than reason from a number it
|
|
763
|
+
* cannot trust. That rule is what surfaced the local-time tick bug during the
|
|
764
|
+
* host probe instead of silently sparing everything.
|
|
765
|
+
*/
|
|
766
|
+
function parseNonNegativeMs(raw) {
|
|
767
|
+
const token = raw.trim();
|
|
768
|
+
if (!/^\d+$/.test(token))
|
|
769
|
+
return undefined;
|
|
770
|
+
const value = Number(token);
|
|
771
|
+
return Number.isFinite(value) ? value : undefined;
|
|
772
|
+
}
|
|
773
|
+
/**
|
|
774
|
+
* POSIX enumeration columns. Exported so a real-`ps` test can assert this
|
|
775
|
+
* exact argument vector is accepted by the host's `ps` — a rejected column
|
|
776
|
+
* would make the whole backstop silently return zero rows, and this is the
|
|
777
|
+
* one part of the fix that cannot be checked from a Windows dev machine.
|
|
778
|
+
*/
|
|
779
|
+
export const POSIX_PS_ARGS = ["-eo", "pid=,ppid=,etime=,args="];
|
|
780
|
+
/** Parse `ps -o etime` output: `[[dd-]hh:]mm:ss`. Returns undefined for any
|
|
781
|
+
* shape the column did not produce (a `ps` without the column emits the
|
|
782
|
+
* command line where the token was expected). */
|
|
783
|
+
export function ageMsFromPosixEtime(raw) {
|
|
784
|
+
const token = raw.trim();
|
|
785
|
+
if (!/^(?:\d+-)?(?:\d+:)?\d+:\d+$/.test(token))
|
|
786
|
+
return undefined;
|
|
787
|
+
let days = 0;
|
|
788
|
+
let rest = token;
|
|
789
|
+
const dash = rest.indexOf("-");
|
|
790
|
+
if (dash >= 0) {
|
|
791
|
+
days = Number(rest.slice(0, dash));
|
|
792
|
+
rest = rest.slice(dash + 1);
|
|
529
793
|
}
|
|
530
|
-
|
|
794
|
+
const parts = rest.split(":").map(Number);
|
|
795
|
+
if (parts.some((part) => !Number.isFinite(part)))
|
|
796
|
+
return undefined;
|
|
797
|
+
const [hours, minutes, seconds] = parts.length === 3 ? parts : [0, parts[0], parts[1]];
|
|
798
|
+
return (((days * 24 + hours) * 60 * 60 + minutes * 60 + seconds) * 1000);
|
|
531
799
|
}
|
|
532
800
|
/**
|
|
533
801
|
* Fire-and-forget REGISTRY-INDEPENDENT backstop sweep (#658): finds
|
|
@@ -539,55 +807,318 @@ async function enumerateManagedProcesses() {
|
|
|
539
807
|
* layer: `sweepOrphans`'s registry-driven path is untouched and remains the
|
|
540
808
|
* cheap, correct common case.
|
|
541
809
|
*
|
|
542
|
-
* Kill-attempt retry: deliberately NO separate retry-tracking state.
|
|
543
|
-
*
|
|
544
|
-
*
|
|
545
|
-
*
|
|
546
|
-
*
|
|
547
|
-
*
|
|
548
|
-
*
|
|
549
|
-
*
|
|
550
|
-
*
|
|
551
|
-
*
|
|
810
|
+
* Kill-attempt retry: deliberately NO separate retry-tracking state. A
|
|
811
|
+
* process that survives its kill stays alive, untracked, and dead-parented,
|
|
812
|
+
* so the next sweep re-classifies it as eligible and tries again. #1857 added
|
|
813
|
+
* the missing half of that story: the survivor is now RECORDED, by identity,
|
|
814
|
+
* through `incrementDegradationCount`, instead of being counted as a reap.
|
|
815
|
+
*
|
|
816
|
+
* Cost and cadence (#1857): the sweep is deferred off the session_start
|
|
817
|
+
* critical path by `scheduleUntrackedOrphanSweep`, its enumeration child has
|
|
818
|
+
* a hard `BACKSTOP_SCAN_TIMEOUT_MS` timeout, and a machine-wide wall-clock
|
|
819
|
+
* cooldown stops every session from paying for it. The cooldown is a
|
|
820
|
+
* TIMESTAMP FILE, not a process-lifetime latch: it re-arms by the clock, so a
|
|
821
|
+
* long-lived host whose sessions keep starting keeps reaping, and a session
|
|
822
|
+
* that outlives the cooldown is no worse off than before, when the sweep ran
|
|
823
|
+
* exactly once at its start.
|
|
824
|
+
*
|
|
825
|
+
* Mutual exclusion (#1864 review F1): the cooldown check and the stamp write
|
|
826
|
+
* were a read-check-write, and an atomic rename makes each WRITE atomic
|
|
827
|
+
* without making the PAIR exclusive — two sessions starting together both read
|
|
828
|
+
* "no stamp", both wrote, and both swept. The claim now runs under
|
|
829
|
+
* `acquireQuarantinePidFileLock` (clients/bounded-pid-file-lock.ts:179), the
|
|
830
|
+
* repo's existing async cross-process lock for work that spans awaited I/O.
|
|
831
|
+
* The lock is taken FIRST and there is exactly ONE cooldown read, inside it,
|
|
832
|
+
* so check and claim are a single serialized step rather than a pair with a
|
|
833
|
+
* window between them. A contended sweep skips rather than waits: the loser
|
|
834
|
+
* has nothing useful to do, and blocking would put a best-effort background
|
|
835
|
+
* sweep back on somebody's clock.
|
|
552
836
|
*
|
|
553
837
|
* Never throws — every step is wrapped so a reap failure cannot block or
|
|
554
838
|
* crash the caller (session_start).
|
|
555
839
|
*/
|
|
556
|
-
export async function sweepUntrackedOrphans() {
|
|
557
|
-
if (!isInstanceRegistryEnabled())
|
|
558
|
-
return;
|
|
840
|
+
export async function sweepUntrackedOrphans(options = {}) {
|
|
559
841
|
const startedAt = Date.now();
|
|
842
|
+
if (!isInstanceRegistryEnabled()) {
|
|
843
|
+
return logBackstopOutcome("disabled", startedAt, {});
|
|
844
|
+
}
|
|
845
|
+
const cooldownMs = Math.max(0, options.cooldownMs ?? BACKSTOP_COOLDOWN_MS);
|
|
846
|
+
let release = null;
|
|
560
847
|
try {
|
|
561
|
-
|
|
848
|
+
// LOCK FIRST, then read the stamp. The ORDER is the fix. A cooldown
|
|
849
|
+
// check outside the lock followed by a stamp write inside it is still a
|
|
850
|
+
// read-check-write: two processes both pass the outside check before
|
|
851
|
+
// either acquires. Keeping a second check inside the lock as well would
|
|
852
|
+
// leave the outside one doing nothing a mutation could detect, so there
|
|
853
|
+
// is exactly ONE check and it lives inside the lock — check and claim
|
|
854
|
+
// are one serialized step. Locking before an eventual skip costs one
|
|
855
|
+
// mkdir, on a timer that already fired 30 seconds after session start.
|
|
856
|
+
release = await acquireBackstopLock();
|
|
857
|
+
if (release === null) {
|
|
858
|
+
// Another process holds the sweep right now. Not an error, and not a
|
|
859
|
+
// cooldown either — a distinct record, because "somebody else is
|
|
860
|
+
// sweeping" and "we swept recently" have different causes.
|
|
861
|
+
return logBackstopOutcome("concurrent", startedAt, {});
|
|
862
|
+
}
|
|
863
|
+
const throttled = await isWithinCooldown(Date.now(), cooldownMs, options);
|
|
864
|
+
if (throttled !== undefined) {
|
|
865
|
+
return logBackstopOutcome("cooldown", startedAt, {
|
|
866
|
+
sinceLastMs: throttled,
|
|
867
|
+
});
|
|
868
|
+
}
|
|
869
|
+
// Claim the cooldown slot BEFORE the scan, not after: a sweep that dies
|
|
870
|
+
// mid-scan must not re-run at full cost on every retry.
|
|
871
|
+
await writeBackstopStamp(startedAt);
|
|
872
|
+
const [registry, scan] = await Promise.all([
|
|
562
873
|
readInstanceRegistry(),
|
|
563
|
-
enumerateManagedProcesses(
|
|
874
|
+
enumerateManagedProcesses({
|
|
875
|
+
timeoutMs: options.scanTimeoutMs,
|
|
876
|
+
verifyAttempts: options.verifyAttempts,
|
|
877
|
+
verifyIntervalMs: options.verifyIntervalMs,
|
|
878
|
+
}),
|
|
564
879
|
]);
|
|
565
|
-
if (
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
880
|
+
if (scan.status !== "ok") {
|
|
881
|
+
// An enumeration that errored or timed out produced an EMPTY result
|
|
882
|
+
// for a reason that is not "nothing to reap" — record it as such.
|
|
883
|
+
recordDegradationOnce({
|
|
884
|
+
kind: "orphan-backstop-scan-failed",
|
|
885
|
+
subject: isWindows ? "win32-cim" : "posix-ps",
|
|
886
|
+
reason: `process enumeration ${scan.status}`,
|
|
887
|
+
});
|
|
888
|
+
return logBackstopOutcome("error", startedAt, {
|
|
889
|
+
scanStatus: scan.status,
|
|
890
|
+
scannerKill: scan.timeoutKill,
|
|
891
|
+
scanned: scan.processes.length,
|
|
892
|
+
});
|
|
572
893
|
}
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
killed: killedCount,
|
|
582
|
-
},
|
|
894
|
+
const partition = partitionBackstopCandidates(scan.processes, registry, realIsPidAlive, { graceMs: options.graceMs });
|
|
895
|
+
for (const proc of partition.unknownAge) {
|
|
896
|
+
// A spared kill must never be silent: without this the guard would
|
|
897
|
+
// look identical to "found nothing".
|
|
898
|
+
incrementDegradationCount({
|
|
899
|
+
kind: "orphan-backstop-age-unknown",
|
|
900
|
+
subject: describeManagedProcess(proc),
|
|
901
|
+
reason: "OS snapshot reported no usable process creation time",
|
|
583
902
|
});
|
|
584
903
|
}
|
|
585
|
-
|
|
586
|
-
|
|
904
|
+
const killed = [];
|
|
905
|
+
const unverified = [];
|
|
906
|
+
for (const proc of partition.eligible) {
|
|
907
|
+
const identity = describeManagedProcess(proc);
|
|
908
|
+
const outcome = await killPidTree(proc.pid, {
|
|
909
|
+
verifyAttempts: options.verifyAttempts,
|
|
910
|
+
verifyIntervalMs: options.verifyIntervalMs,
|
|
911
|
+
});
|
|
912
|
+
if (outcome === "gone") {
|
|
913
|
+
killed.push(identity);
|
|
914
|
+
continue;
|
|
915
|
+
}
|
|
916
|
+
unverified.push(identity);
|
|
917
|
+
incrementDegradationCount({
|
|
918
|
+
kind: "orphan-backstop-kill-unverified",
|
|
919
|
+
subject: identity,
|
|
920
|
+
reason: `kill attempted, process still ${outcome === "alive" ? "alive" : "unresolvable"}`,
|
|
921
|
+
});
|
|
922
|
+
}
|
|
923
|
+
const retryAt = scheduleGraceRetryIfNeeded(partition, options);
|
|
924
|
+
return logBackstopOutcome(classifyKillOutcome(killed.length, unverified.length), startedAt, {
|
|
925
|
+
scanStatus: scan.status,
|
|
926
|
+
scanned: scan.processes.length,
|
|
927
|
+
eligible: partition.eligible.length,
|
|
928
|
+
tooFresh: partition.tooFresh.length,
|
|
929
|
+
unknownAge: partition.unknownAge.length,
|
|
930
|
+
killed: killed.length,
|
|
931
|
+
killUnverified: unverified.length,
|
|
932
|
+
// Identity of the kill, bounded — #1857 item 3. Counts alone can
|
|
933
|
+
// never answer "which process did we kill?".
|
|
934
|
+
killedProcesses: killed.slice(0, BACKSTOP_IDENTITY_LOG_LIMIT),
|
|
935
|
+
unverifiedProcesses: unverified.slice(0, BACKSTOP_IDENTITY_LOG_LIMIT),
|
|
936
|
+
graceRetryInMs: retryAt,
|
|
937
|
+
});
|
|
938
|
+
}
|
|
939
|
+
catch (error) {
|
|
940
|
+
// The sweep must never throw out of session_start — but "threw" is now
|
|
941
|
+
// a distinguishable record rather than a silent absence.
|
|
942
|
+
recordDegradationOnce({
|
|
943
|
+
kind: "orphan-backstop-scan-failed",
|
|
944
|
+
subject: "sweep",
|
|
945
|
+
reason: `sweep threw: ${error instanceof Error ? error.message : String(error)}`,
|
|
946
|
+
});
|
|
947
|
+
return logBackstopOutcome("error", startedAt, { threw: true });
|
|
948
|
+
}
|
|
949
|
+
finally {
|
|
950
|
+
if (release) {
|
|
951
|
+
await release().catch(() => {
|
|
952
|
+
// A lock we cannot release goes stale on its own timer.
|
|
953
|
+
});
|
|
587
954
|
}
|
|
588
955
|
}
|
|
956
|
+
}
|
|
957
|
+
/** Map verified/unverified kill counts onto the outcome. The only place that
|
|
958
|
+
* mapping exists. */
|
|
959
|
+
function classifyKillOutcome(killed, unverified) {
|
|
960
|
+
if (unverified === 0)
|
|
961
|
+
return killed > 0 ? "reaped" : "clean";
|
|
962
|
+
return killed > 0 ? "partial" : "unverified";
|
|
963
|
+
}
|
|
964
|
+
/**
|
|
965
|
+
* Minimum wall-clock gap between backstop sweeps, machine-wide. The backstop
|
|
966
|
+
* catches processes that are already orphaned and will stay orphaned; it is
|
|
967
|
+
* not time-critical, so paying its cost once per half hour instead of once
|
|
968
|
+
* per session_start removes essentially all of its aggregate cost.
|
|
969
|
+
*/
|
|
970
|
+
export const BACKSTOP_COOLDOWN_MS = 30 * 60 * 1000;
|
|
971
|
+
/** How long after session_start the deferred sweep fires. Chosen to clear the
|
|
972
|
+
* warmup window it was measured starving: `warmup_total` median 3288ms,
|
|
973
|
+
* worst on record 5480ms (#1857). */
|
|
974
|
+
export const BACKSTOP_START_DELAY_MS = 30_000;
|
|
975
|
+
/** Cap on process identities embedded in one latency record. */
|
|
976
|
+
const BACKSTOP_IDENTITY_LOG_LIMIT = 10;
|
|
977
|
+
/**
|
|
978
|
+
* Extra margin on top of the spawn grace before the follow-up sweep runs, so
|
|
979
|
+
* a candidate that was 1ms too fresh is comfortably past the grace by the
|
|
980
|
+
* time the retry looks at it again.
|
|
981
|
+
*/
|
|
982
|
+
export const BACKSTOP_GRACE_RETRY_MARGIN_MS = 30_000;
|
|
983
|
+
/** How long the sweep lock may be held before another process reclaims it.
|
|
984
|
+
* Well above the scan timeout plus the kill budget, and irrelevant when the
|
|
985
|
+
* holder dies — the lock reclaims immediately on a dead owner pid
|
|
986
|
+
* (clients/bounded-pid-file-lock.ts:90). */
|
|
987
|
+
const BACKSTOP_LOCK_STALE_MS = 120_000;
|
|
988
|
+
/**
|
|
989
|
+
* Schedule the backstop sweep OFF the session_start critical path. The timer
|
|
990
|
+
* is `unref`'d, so a settled one-shot `pi --print` exits without waiting and
|
|
991
|
+
* the sweep simply does not happen for that invocation — which is correct: a
|
|
992
|
+
* process too short-lived to reach the delay is also too short-lived to be
|
|
993
|
+
* the right place to spend seconds of CIM time.
|
|
994
|
+
*/
|
|
995
|
+
export function scheduleUntrackedOrphanSweep(delayMs = BACKSTOP_START_DELAY_MS, options = {}) {
|
|
996
|
+
const timer = setTimeout(() => {
|
|
997
|
+
void sweepUntrackedOrphans(options).catch(() => {
|
|
998
|
+
// sweepUntrackedOrphans never rejects; belt-and-braces.
|
|
999
|
+
});
|
|
1000
|
+
}, Math.max(0, delayMs));
|
|
1001
|
+
timer.unref();
|
|
1002
|
+
return timer;
|
|
1003
|
+
}
|
|
1004
|
+
/**
|
|
1005
|
+
* Arm ONE follow-up sweep when the spawn grace spared a candidate (#1864
|
|
1006
|
+
* review F2).
|
|
1007
|
+
*
|
|
1008
|
+
* Without it, the grace guard could spare an orphan indefinitely: the sweep
|
|
1009
|
+
* runs once per session, a candidate younger than the grace is spared, and a
|
|
1010
|
+
* long-lived session never looks again. That is a regression against the old
|
|
1011
|
+
* always-at-session-start sweep, which at least re-examined the process every
|
|
1012
|
+
* session.
|
|
1013
|
+
*
|
|
1014
|
+
* The retry passes `force` (the cooldown it would otherwise hit is the stamp
|
|
1015
|
+
* this same sweep just wrote) and `allowGraceRetry: false`, so the chain is
|
|
1016
|
+
* exactly one deep. It still takes the lock, so it cannot race a concurrent
|
|
1017
|
+
* sweep. Residual: if the process exits before the timer fires, the spared
|
|
1018
|
+
* candidate waits for the next session's sweep — the retry is unref'd, and a
|
|
1019
|
+
* background reap must never hold a settled one-shot open.
|
|
1020
|
+
*
|
|
1021
|
+
* Returns the delay armed, or undefined when nothing was spared.
|
|
1022
|
+
*/
|
|
1023
|
+
function scheduleGraceRetryIfNeeded(partition, options) {
|
|
1024
|
+
if (options.allowGraceRetry === false)
|
|
1025
|
+
return undefined;
|
|
1026
|
+
if (partition.tooFresh.length === 0)
|
|
1027
|
+
return undefined;
|
|
1028
|
+
const graceMs = Math.max(0, options.graceMs ?? BACKSTOP_SPAWN_GRACE_MS);
|
|
1029
|
+
const delayMs = Math.max(0, options.graceRetryDelayMs ?? graceMs + BACKSTOP_GRACE_RETRY_MARGIN_MS);
|
|
1030
|
+
scheduleUntrackedOrphanSweep(delayMs, {
|
|
1031
|
+
...options,
|
|
1032
|
+
force: true,
|
|
1033
|
+
allowGraceRetry: false,
|
|
1034
|
+
});
|
|
1035
|
+
return delayMs;
|
|
1036
|
+
}
|
|
1037
|
+
/** Serialize the sweep across processes. Returns null when another process
|
|
1038
|
+
* holds it — the caller skips rather than waits (`waitMs: 0`). */
|
|
1039
|
+
async function acquireBackstopLock() {
|
|
1040
|
+
try {
|
|
1041
|
+
return await acquireQuarantinePidFileLock(path.join(getGlobalPiLensDir(), "orphan-backstop.lock"), {
|
|
1042
|
+
waitMs: 0,
|
|
1043
|
+
retryMs: 50,
|
|
1044
|
+
staleMs: BACKSTOP_LOCK_STALE_MS,
|
|
1045
|
+
timeoutMessage: "orphan backstop sweep lock contended",
|
|
1046
|
+
onContention: "skip-log",
|
|
1047
|
+
logContention: () => {
|
|
1048
|
+
// The caller emits the `concurrent` record; nothing to add here.
|
|
1049
|
+
},
|
|
1050
|
+
});
|
|
1051
|
+
}
|
|
589
1052
|
catch {
|
|
590
|
-
//
|
|
1053
|
+
// A lock we cannot even attempt (unwritable global dir) must not disable
|
|
1054
|
+
// the backstop — fall through unlocked, exactly as before this fix.
|
|
1055
|
+
return async () => { };
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
/** Milliseconds since the last sweep when the cooldown is in force, else
|
|
1059
|
+
* undefined. Centralized so the pre-check and the in-lock re-check cannot
|
|
1060
|
+
* drift apart. */
|
|
1061
|
+
async function isWithinCooldown(now, cooldownMs, options) {
|
|
1062
|
+
if (cooldownMs <= 0 || options.force)
|
|
1063
|
+
return undefined;
|
|
1064
|
+
const lastSweepAt = await readBackstopStamp();
|
|
1065
|
+
if (lastSweepAt === undefined)
|
|
1066
|
+
return undefined;
|
|
1067
|
+
const sinceLastMs = now - lastSweepAt;
|
|
1068
|
+
return sinceLastMs < cooldownMs ? sinceLastMs : undefined;
|
|
1069
|
+
}
|
|
1070
|
+
/** Short, stable identity for one managed process: which binary, which pid.
|
|
1071
|
+
* Full command lines are user paths and can be long, so the record carries
|
|
1072
|
+
* the matched managed-binary name plus the pid. */
|
|
1073
|
+
function describeManagedProcess(proc) {
|
|
1074
|
+
const lower = proc.command.toLowerCase();
|
|
1075
|
+
const matched = MANAGED_BINARY_NAMES.find((name) => lower.includes(name.toLowerCase()));
|
|
1076
|
+
return `${matched ?? "unknown"}#${proc.pid}`;
|
|
1077
|
+
}
|
|
1078
|
+
function logBackstopOutcome(outcome, startedAt, metadata) {
|
|
1079
|
+
try {
|
|
1080
|
+
logLatency({
|
|
1081
|
+
type: "phase",
|
|
1082
|
+
phase: "orphan_backstop_reaped",
|
|
1083
|
+
filePath: "",
|
|
1084
|
+
durationMs: Date.now() - startedAt,
|
|
1085
|
+
metadata: { outcome, ...metadata },
|
|
1086
|
+
});
|
|
1087
|
+
}
|
|
1088
|
+
catch {
|
|
1089
|
+
// best-effort logging only
|
|
1090
|
+
}
|
|
1091
|
+
return outcome;
|
|
1092
|
+
}
|
|
1093
|
+
/** Machine-wide cooldown stamp. A file, not a module-level flag: the sweep is
|
|
1094
|
+
* machine-scoped (every pi-lens process shares one OS process table), and
|
|
1095
|
+
* process-lifetime state cannot express "30 minutes have passed". */
|
|
1096
|
+
function backstopStampPath() {
|
|
1097
|
+
return path.join(getGlobalPiLensDir(), "orphan-backstop.json");
|
|
1098
|
+
}
|
|
1099
|
+
async function readBackstopStamp() {
|
|
1100
|
+
try {
|
|
1101
|
+
const raw = await fs.promises.readFile(backstopStampPath(), "utf8");
|
|
1102
|
+
const parsed = JSON.parse(raw);
|
|
1103
|
+
const value = parsed?.lastSweepAt;
|
|
1104
|
+
return typeof value === "number" && Number.isFinite(value)
|
|
1105
|
+
? value
|
|
1106
|
+
: undefined;
|
|
1107
|
+
}
|
|
1108
|
+
catch {
|
|
1109
|
+
// Absent or unreadable ⇒ no cooldown in force, so the sweep runs. A
|
|
1110
|
+
// corrupt stamp must never be able to disable the backstop permanently.
|
|
1111
|
+
return undefined;
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
async function writeBackstopStamp(at) {
|
|
1115
|
+
try {
|
|
1116
|
+
await fs.promises.mkdir(getGlobalPiLensDir(), { recursive: true });
|
|
1117
|
+
await writeFileAtomicAsync(backstopStampPath(), JSON.stringify({ lastSweepAt: at }));
|
|
1118
|
+
}
|
|
1119
|
+
catch {
|
|
1120
|
+
// best-effort — a stamp we cannot persist just means the next session
|
|
1121
|
+
// pays for another sweep, never a crash.
|
|
591
1122
|
}
|
|
592
1123
|
}
|
|
593
1124
|
/**
|
|
@@ -615,20 +1146,34 @@ export async function sweepOrphans() {
|
|
|
615
1146
|
const cmdlines = await queryCommandLines(candidatePids);
|
|
616
1147
|
const matchProcess = buildIdentityMatcher(cmdlines);
|
|
617
1148
|
const decision = decideOrphanReaping(registry, realIsPidAlive, matchProcess);
|
|
1149
|
+
// #1857 class sweep: the registry-driven path spelled the same
|
|
1150
|
+
// attempt-counted-as-kill defect as the backstop, so it gets the same
|
|
1151
|
+
// verified accounting and the same bounded, identity-carrying record.
|
|
618
1152
|
let killedCount = 0;
|
|
1153
|
+
let unverifiedCount = 0;
|
|
619
1154
|
const killedServerIds = [];
|
|
1155
|
+
const killAndAccount = async (pid, serverId) => {
|
|
1156
|
+
const outcome = await killPidTree(pid);
|
|
1157
|
+
if (outcome === "gone") {
|
|
1158
|
+
killedCount++;
|
|
1159
|
+
killedServerIds.push(serverId);
|
|
1160
|
+
return;
|
|
1161
|
+
}
|
|
1162
|
+
unverifiedCount++;
|
|
1163
|
+
incrementDegradationCount({
|
|
1164
|
+
kind: "orphan-reap-kill-unverified",
|
|
1165
|
+
subject: `${serverId}#${pid}`,
|
|
1166
|
+
reason: `kill attempted, process still ${outcome === "alive" ? "alive" : "unresolvable"}`,
|
|
1167
|
+
});
|
|
1168
|
+
};
|
|
620
1169
|
for (const child of decision.childrenToKill) {
|
|
621
|
-
await
|
|
622
|
-
killedCount++;
|
|
623
|
-
killedServerIds.push(child.serverId);
|
|
1170
|
+
await killAndAccount(child.pid, child.serverId);
|
|
624
1171
|
}
|
|
625
1172
|
for (const search of decision.markerSearches) {
|
|
626
1173
|
try {
|
|
627
1174
|
const pids = await findPidsByMarkerWindows(search.marker);
|
|
628
1175
|
for (const pid of pids) {
|
|
629
|
-
await
|
|
630
|
-
killedCount++;
|
|
631
|
-
killedServerIds.push(search.serverId);
|
|
1176
|
+
await killAndAccount(pid, search.serverId);
|
|
632
1177
|
}
|
|
633
1178
|
}
|
|
634
1179
|
catch {
|
|
@@ -661,6 +1206,7 @@ export async function sweepOrphans() {
|
|
|
661
1206
|
deadInstances: decision.deadInstances.length,
|
|
662
1207
|
staleInstances: decision.staleInstances.length,
|
|
663
1208
|
killed: killedCount,
|
|
1209
|
+
killUnverified: unverifiedCount,
|
|
664
1210
|
serverIds: killedServerIds,
|
|
665
1211
|
markerSearches: decision.markerSearches.length,
|
|
666
1212
|
},
|