@herbertgao/pi-extensions 2026.8.13 → 2026.8.15
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 +19 -14
- package/THIRD_PARTY_NOTICES.md +0 -1
- package/node_modules/@czottmann/pi-automode/CHANGELOG.md +19 -0
- package/node_modules/@czottmann/pi-automode/README.md +5 -1
- package/node_modules/@czottmann/pi-automode/extensions/auto-mode/classifier.ts +126 -13
- package/node_modules/@czottmann/pi-automode/extensions/auto-mode/config.ts +7 -3
- package/node_modules/@czottmann/pi-automode/extensions/auto-mode/constants.ts +2 -0
- package/node_modules/@czottmann/pi-automode/extensions/auto-mode/hard-deny.ts +134 -23
- package/node_modules/@czottmann/pi-automode/package.json +1 -1
- package/node_modules/@herbertgao/{pi-stash → pi-bark}/LICENSE +4 -5
- package/node_modules/@herbertgao/pi-bark/README.md +44 -0
- package/node_modules/@herbertgao/pi-bark/assets/pi-icon.png +0 -0
- package/node_modules/@herbertgao/pi-bark/package.json +53 -0
- package/node_modules/@herbertgao/pi-bark/src/index.ts +178 -0
- 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/package.json +3 -3
- package/node_modules/@herbertgao/pi-subagents/CHANGELOG.md +6 -0
- package/node_modules/@herbertgao/pi-subagents/package.json +2 -1
- package/node_modules/@herbertgao/pi-subagents/src/ui/conversation-viewer.ts +8 -1
- package/node_modules/@juicesharp/rpiv-ask-user-question/package.json +2 -2
- package/node_modules/@narumitw/pi-btw/README.md +125 -88
- package/node_modules/@narumitw/pi-btw/dist/index.ts +140 -45
- package/node_modules/@narumitw/pi-btw/dist/index.ts.map +2 -2
- package/node_modules/@narumitw/pi-btw/package.json +5 -8
- package/node_modules/@narumitw/pi-btw/src/btw.ts +31 -26
- package/node_modules/@narumitw/pi-btw/src/fullscreen-ui.ts +106 -17
- package/node_modules/@narumitw/pi-btw/src/menu.ts +34 -2
- package/node_modules/@narumitw/pi-btw/src/settings.ts +16 -0
- package/node_modules/@tifan/pi-handoff/README.md +36 -13
- package/node_modules/@tifan/pi-handoff/package.json +2 -2
- package/node_modules/@tifan/pi-handoff/src/index.ts +160 -4
- package/node_modules/@tifan/pi-preferred-thinking/README.md +1 -1
- package/node_modules/@tifan/pi-preferred-thinking/package.json +1 -1
- package/node_modules/@tifan/pi-preferred-thinking/src/index.ts +15 -2
- package/node_modules/@tifan/pi-recap/README.md +1 -1
- package/node_modules/@tifan/pi-recap/package.json +2 -2
- package/node_modules/@tifan/pi-recap/src/index.ts +15 -0
- package/node_modules/@tifan/pi-rename/README.md +42 -21
- package/node_modules/@tifan/pi-rename/package.json +1 -1
- package/node_modules/@tifan/pi-rename/src/index.ts +62 -11
- package/node_modules/@tifan/pi-rename/src/language.ts +18 -0
- package/node_modules/@tifan/pi-rename/src/models.ts +69 -13
- package/node_modules/@tifan/pi-rename/src/naming.ts +36 -7
- package/node_modules/@tifan/pi-rename/src/sanitize.ts +22 -14
- package/node_modules/pi-lens/CHANGELOG.md +320 -0
- package/node_modules/pi-lens/config/dependency-cruiser-eager-allowlist.json +79 -0
- package/node_modules/pi-lens/dist/clients/actionable-warnings-logger.js +16 -1
- package/node_modules/pi-lens/dist/clients/ast-grep-client.js +13 -6
- package/node_modules/pi-lens/dist/clients/availability-probe-flight.js +12 -0
- package/node_modules/pi-lens/dist/clients/biome-client.js +7 -0
- package/node_modules/pi-lens/dist/clients/blocker-freshness.js +20 -0
- package/node_modules/pi-lens/dist/clients/bounded-telemetry.js +56 -19
- package/node_modules/pi-lens/dist/clients/build-identity.js +95 -0
- package/node_modules/pi-lens/dist/clients/bus-publish.js +3 -0
- package/node_modules/pi-lens/dist/clients/cache/rule-cache.js +2 -1
- package/node_modules/pi-lens/dist/clients/cache-observability.js +2 -1
- package/node_modules/pi-lens/dist/clients/cascade-logger.js +15 -1
- package/node_modules/pi-lens/dist/clients/dead-code-client.js +14 -11
- package/node_modules/pi-lens/dist/clients/degradation-ledger.js +56 -4
- package/node_modules/pi-lens/dist/clients/demoted-finding-render.js +14 -0
- package/node_modules/pi-lens/dist/clients/dependency-checker.js +20 -3
- package/node_modules/pi-lens/dist/clients/deps/minimatch.js +1 -1
- package/node_modules/pi-lens/dist/clients/dispatch/collect-later-tier.js +39 -0
- package/node_modules/pi-lens/dist/clients/dispatch/dispatcher.js +150 -9
- package/node_modules/pi-lens/dist/clients/dispatch/fact-store.js +231 -9
- package/node_modules/pi-lens/dist/clients/dispatch/integration.js +205 -100
- package/node_modules/pi-lens/dist/clients/dispatch/pending-runner-findings.js +87 -0
- package/node_modules/pi-lens/dist/clients/dispatch/plan.js +21 -5
- package/node_modules/pi-lens/dist/clients/dispatch/runners/ast-grep-napi.js +174 -55
- package/node_modules/pi-lens/dist/clients/dispatch/runners/eslint.js +5 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/helm-lint.js +16 -3
- package/node_modules/pi-lens/dist/clients/dispatch/runners/helm-render.js +54 -9
- package/node_modules/pi-lens/dist/clients/dispatch/runners/index.js +1 -3
- package/node_modules/pi-lens/dist/clients/dispatch/runners/lsp.js +37 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/markdownlint.js +4 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/oxlint.js +11 -4
- package/node_modules/pi-lens/dist/clients/dispatch/runners/psscriptanalyzer.js +4 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/rust-clippy.js +8 -3
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/runner-helpers.js +76 -25
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/spawn-outcome.js +4 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/tool-failure.js +19 -7
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/toolchain-availability.js +7 -13
- package/node_modules/pi-lens/dist/clients/dispatch/runners/yaml-rule-parser.js +51 -17
- package/node_modules/pi-lens/dist/clients/event-loop-monitor.js +66 -1
- package/node_modules/pi-lens/dist/clients/file-utils.js +157 -7
- package/node_modules/pi-lens/dist/clients/finding-delivery-gate.js +5 -0
- package/node_modules/pi-lens/dist/clients/formatters.js +28 -3
- package/node_modules/pi-lens/dist/clients/git-guard.js +197 -47
- package/node_modules/pi-lens/dist/clients/git-tracked-ignore.js +32 -1
- package/node_modules/pi-lens/dist/clients/govulncheck-client.js +5 -1
- package/node_modules/pi-lens/dist/clients/gzip-stage-write.js +7 -0
- package/node_modules/pi-lens/dist/clients/installer/index.js +131 -34
- package/node_modules/pi-lens/dist/clients/installer/managed-tool-refresh.js +3 -1
- package/node_modules/pi-lens/dist/clients/instance-reaper.js +4 -13
- package/node_modules/pi-lens/dist/clients/instance-registry-lock.js +145 -0
- package/node_modules/pi-lens/dist/clients/instance-registry.js +432 -122
- package/node_modules/pi-lens/dist/clients/jscpd-client.js +6 -1
- package/node_modules/pi-lens/dist/clients/language-profile.js +4 -2
- package/node_modules/pi-lens/dist/clients/latency-logger.js +19 -1
- package/node_modules/pi-lens/dist/clients/lens-flag-registry.js +8 -0
- package/node_modules/pi-lens/dist/clients/lens-map.js +3 -1
- package/node_modules/pi-lens/dist/clients/lsp/cascade-tier.js +4 -0
- package/node_modules/pi-lens/dist/clients/lsp/client.js +516 -31
- package/node_modules/pi-lens/dist/clients/lsp/config.js +25 -2
- package/node_modules/pi-lens/dist/clients/lsp/diagnostic-binding.js +9 -4
- package/node_modules/pi-lens/dist/clients/lsp/index.js +509 -182
- package/node_modules/pi-lens/dist/clients/lsp/jvm-runtime.js +5 -0
- package/node_modules/pi-lens/dist/clients/lsp/launch.js +36 -8
- package/node_modules/pi-lens/dist/clients/lsp/pending-aux-coverage.js +116 -4
- package/node_modules/pi-lens/dist/clients/lsp/server.js +142 -13
- package/node_modules/pi-lens/dist/clients/lsp/session-roots.js +99 -0
- package/node_modules/pi-lens/dist/clients/lsp/workspace-diagnostics-cache.js +16 -6
- package/node_modules/pi-lens/dist/clients/lsp/workspace-sweep-hold.js +32 -23
- package/node_modules/pi-lens/dist/clients/lsp-mutation.js +16 -2
- package/node_modules/pi-lens/dist/clients/mcp/analyze.js +12 -6
- package/node_modules/pi-lens/dist/clients/mcp/session.js +14 -1
- package/node_modules/pi-lens/dist/clients/memory-sampler.js +23 -1
- package/node_modules/pi-lens/dist/clients/message-end-attribution.js +30 -0
- package/node_modules/pi-lens/dist/clients/metrics-history.js +21 -4
- package/node_modules/pi-lens/dist/clients/opaque-mutation-scan.js +232 -18
- package/node_modules/pi-lens/dist/clients/package-manager.js +6 -18
- package/node_modules/pi-lens/dist/clients/path-keyed-map.js +14 -1
- package/node_modules/pi-lens/dist/clients/path-utils.js +32 -0
- package/node_modules/pi-lens/dist/clients/pipeline.js +0 -8
- package/node_modules/pi-lens/dist/clients/process-singletons.js +210 -0
- package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/madge.js +2 -1
- package/node_modules/pi-lens/dist/clients/project-diagnostics/scanner.js +10 -4
- package/node_modules/pi-lens/dist/clients/project-snapshot.js +33 -11
- package/node_modules/pi-lens/dist/clients/read-guard-logger.js +24 -2
- package/node_modules/pi-lens/dist/clients/read-guard.js +74 -7
- package/node_modules/pi-lens/dist/clients/recent-touches.js +17 -5
- package/node_modules/pi-lens/dist/clients/review-graph/builder.js +639 -62
- package/node_modules/pi-lens/dist/clients/review-graph/tsconfig-paths.js +5 -1
- package/node_modules/pi-lens/dist/clients/review-graph-logger.js +21 -1
- package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +68 -0
- package/node_modules/pi-lens/dist/clients/runtime-session.js +30 -12
- package/node_modules/pi-lens/dist/clients/runtime-tool-call.js +15 -0
- package/node_modules/pi-lens/dist/clients/runtime-tool-result.js +106 -7
- package/node_modules/pi-lens/dist/clients/runtime-turn.js +242 -27
- package/node_modules/pi-lens/dist/clients/safe-spawn.js +158 -18
- package/node_modules/pi-lens/dist/clients/security-scan-client.js +48 -17
- package/node_modules/pi-lens/dist/clients/session-lifecycle.js +238 -42
- package/node_modules/pi-lens/dist/clients/sg-runner.js +27 -15
- package/node_modules/pi-lens/dist/clients/shared-checkout-guard.js +338 -0
- package/node_modules/pi-lens/dist/clients/spawn-output-cap.js +37 -0
- package/node_modules/pi-lens/dist/clients/spawn-timeout-cooldown.js +15 -1
- package/node_modules/pi-lens/dist/clients/startup-scan.js +6 -7
- package/node_modules/pi-lens/dist/clients/startup-timing.js +69 -4
- package/node_modules/pi-lens/dist/clients/string-utils.js +20 -0
- package/node_modules/pi-lens/dist/clients/test-runner-client.js +419 -88
- package/node_modules/pi-lens/dist/clients/tool-policy.js +1 -1
- package/node_modules/pi-lens/dist/clients/tree-sitter-client.js +11 -5
- package/node_modules/pi-lens/dist/clients/tree-sitter-logger.js +17 -1
- package/node_modules/pi-lens/dist/clients/warm-attach.js +8 -10
- package/node_modules/pi-lens/dist/clients/word-index-logger.js +13 -1
- package/node_modules/pi-lens/dist/clients/word-index-store.js +448 -0
- package/node_modules/pi-lens/dist/clients/word-index.js +582 -104
- package/node_modules/pi-lens/dist/clients/workspace-topology.js +17 -0
- package/node_modules/pi-lens/dist/clients/zizmor-config.js +26 -6
- package/node_modules/pi-lens/dist/index.js +11243 -6651
- package/node_modules/pi-lens/dist/mcp/build-staleness.js +8 -7
- package/node_modules/pi-lens/dist/mcp/cli.js +3 -1
- package/node_modules/pi-lens/dist/mcp/server.js +6 -1
- package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +4 -0
- package/node_modules/pi-lens/dist/tools/lsp-diagnostics.js +14 -5
- package/node_modules/pi-lens/dist/tools/lsp-navigation.js +3 -1
- package/node_modules/pi-lens/docs/agent-guide.md +4 -2
- package/node_modules/pi-lens/docs/ast-grep_rules_catalog.md +41 -10
- package/node_modules/pi-lens/docs/astplayground.md +26 -17
- package/node_modules/pi-lens/package.json +5 -1
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/c-no-malloc-free-test.yml +20 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/cpp-no-malloc-free-test.yml +25 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/cpp-no-printf-test.yml +19 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/go-no-fmt-println-test.yml +19 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/go-no-panic-in-lib-test.yml +17 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/go-no-underscore-func-name-test.yml +18 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/go-prefer-errors-is-test.yml +15 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/go-prefer-string-builder-test.yml +21 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/hyphenated-svg-attribute-test.yml +11 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/kotlin-no-lateinit-test.yml +17 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/kotlin-no-nullable-boolean-test.yml +15 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/kotlin-no-println-test.yml +19 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/kotlin-no-unnecessary-let-test.yml +7 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/kotlin-no-unsafe-cast-test.yml +7 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/kotlin-no-var-test.yml +7 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/kotlin-prefer-data-class-test.yml +25 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/kotlin-prefer-is-empty-test.yml +11 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-important-test.yml +21 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-raw-types-test.yml +30 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-string-concat-in-loop-test.yml +100 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-system-out-println-test.yml +7 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/prefer-string-is-empty-test.yml +7 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/python-optional-type-test.yml +9 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/reducible-list-comprehension-test.yml +11 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/ruby-symbol-to-proc-candidate-test.yml +10 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/rust-no-chars-enumerate-test.yml +7 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/c-no-malloc-free.yml +39 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/cpp-no-malloc-free.yml +45 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/cpp-no-printf.yml +41 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/go-no-fmt-println.yml +43 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/go-no-panic-in-lib.yml +41 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/go-no-underscore-func-name.yml +35 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/go-prefer-errors-is.yml +40 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/go-prefer-string-builder.yml +43 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/hyphenated-svg-attribute.yml +32 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/kotlin-no-lateinit.yml +37 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/kotlin-no-nullable-boolean.yml +33 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/kotlin-no-println.yml +42 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/kotlin-no-unnecessary-let.yml +28 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/kotlin-no-unsafe-cast.yml +28 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/kotlin-no-var.yml +35 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/kotlin-prefer-data-class.yml +66 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/kotlin-prefer-is-empty.yml +35 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-important.yml +21 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-raw-types.yml +33 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-string-concat-in-loop.yml +90 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-system-out-println.yml +16 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/prefer-string-is-empty.yml +11 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/python-optional-type.yml +22 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/reducible-list-comprehension.yml +29 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/ruby-symbol-to-proc-candidate.yml +25 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/rust-no-chars-enumerate.yml +19 -0
- package/node_modules/pi-mcp-adapter/CHANGELOG.md +35 -0
- package/node_modules/pi-mcp-adapter/README.md +5 -2
- package/node_modules/pi-mcp-adapter/cli.js +4 -4
- package/node_modules/pi-mcp-adapter/commands.ts +1 -1
- package/node_modules/pi-mcp-adapter/config.ts +1 -0
- package/node_modules/pi-mcp-adapter/dist/config.js +1 -0
- package/node_modules/pi-mcp-adapter/dist/config.js.map +1 -1
- package/node_modules/pi-mcp-adapter/dist/mcp-bearer-store.d.ts +24 -0
- package/node_modules/pi-mcp-adapter/dist/mcp-bearer-store.js +336 -0
- package/node_modules/pi-mcp-adapter/dist/mcp-bearer-store.js.map +1 -0
- package/node_modules/pi-mcp-adapter/dist/types.d.ts +3 -1
- package/node_modules/pi-mcp-adapter/dist/types.js.map +1 -1
- package/node_modules/pi-mcp-adapter/index.ts +90 -6
- package/node_modules/pi-mcp-adapter/mcp-auth-flow.ts +124 -32
- package/node_modules/pi-mcp-adapter/mcp-bearer-store.ts +0 -2
- package/node_modules/pi-mcp-adapter/mcp-oauth-provider.ts +89 -0
- package/node_modules/pi-mcp-adapter/mcp-references.ts +9 -1
- package/node_modules/pi-mcp-adapter/package.json +1 -1
- package/node_modules/pi-mcp-adapter/proxy-modes.ts +62 -22
- package/node_modules/pi-mcp-adapter/request-headers-command.ts +1 -1
- package/node_modules/pi-mcp-adapter/server-manager.ts +6 -0
- package/node_modules/pi-mcp-adapter/types.ts +3 -1
- package/node_modules/pi-web-access/CHANGELOG.md +42 -0
- package/node_modules/pi-web-access/README.md +26 -10
- package/node_modules/pi-web-access/curator-page.ts +12 -3
- package/node_modules/pi-web-access/curator-server.ts +3 -1
- package/node_modules/pi-web-access/extract.ts +88 -13
- package/node_modules/pi-web-access/gemini-search.ts +10 -5
- package/node_modules/pi-web-access/github-extract.ts +47 -3
- package/node_modules/pi-web-access/index.ts +40 -40
- package/node_modules/pi-web-access/package.json +1 -1
- package/node_modules/pi-web-access/page-query.ts +61 -8
- package/node_modules/pi-web-access/xcrawl.ts +264 -0
- package/package.json +19 -22
- package/node_modules/@herbertgao/pi-stash/README.md +0 -34
- package/node_modules/@herbertgao/pi-stash/package.json +0 -51
- package/node_modules/@herbertgao/pi-stash/src/index.ts +0 -118
- package/node_modules/@tifan/pi-titlebar-spinner/README.md +0 -24
- package/node_modules/@tifan/pi-titlebar-spinner/package.json +0 -39
- package/node_modules/@tifan/pi-titlebar-spinner/src/index.ts +0 -49
|
@@ -41,12 +41,21 @@ import { createHash } from "node:crypto";
|
|
|
41
41
|
import { normalizeMapKey } from "./path-utils.js";
|
|
42
42
|
import { freshnessFromMtime } from "./freshness.js";
|
|
43
43
|
import { safeSpawnAsync } from "./safe-spawn.js";
|
|
44
|
+
import { truncatedByOutputCap } from "./spawn-output-cap.js";
|
|
44
45
|
/** Stop hashing once this many cumulative bytes were read (per capture). */
|
|
45
46
|
export const OPAQUE_HASH_BUDGET_BYTES = 8 * 1024 * 1024;
|
|
46
47
|
/** Hard cap on scanned files — beyond it the verdict is coverage-unknown. */
|
|
47
48
|
export const OPAQUE_SCAN_MAX_FILES = 2000;
|
|
48
49
|
/** How far before recorded start an earlier write may still be attributed. */
|
|
49
50
|
export const OPAQUE_MTIME_TOLERANCE_MS = 150;
|
|
51
|
+
// `--untracked-files=all` lists untracked files individually instead of
|
|
52
|
+
// collapsing them per directory (it does NOT add ignored paths — that needs
|
|
53
|
+
// `--ignored`), so the worst realistic case is a working tree with a large
|
|
54
|
+
// unignored generated or vendored directory, and a rename entry costs two
|
|
55
|
+
// paths. 16 MiB is a blast-radius bound on that, well past any tree this can
|
|
56
|
+
// answer usefully about, and it is what makes the truncation guard below
|
|
57
|
+
// reachable at all (#2100).
|
|
58
|
+
const MAX_GIT_STATUS_OUTPUT_BYTES = 16 * 1024 * 1024;
|
|
50
59
|
export async function captureFileStats(root, options = {}) {
|
|
51
60
|
const budgetMs = options.budgetMs ?? 50;
|
|
52
61
|
try {
|
|
@@ -152,6 +161,7 @@ const gitRepoMemo = new Map();
|
|
|
152
161
|
export function resetOpaqueMutationState() {
|
|
153
162
|
getOpaqueBaselineStore().takeAllForTest();
|
|
154
163
|
gitRepoMemo.clear();
|
|
164
|
+
gitToplevelMemo.clear();
|
|
155
165
|
}
|
|
156
166
|
/** Cached git-worktree probe (repos don't stop being git mid-session). */
|
|
157
167
|
export async function isGitWorktree(root) {
|
|
@@ -166,14 +176,141 @@ export async function isGitWorktree(root) {
|
|
|
166
176
|
}
|
|
167
177
|
export function _resetGitWorktreeMemoForTests() {
|
|
168
178
|
gitRepoMemo.clear();
|
|
179
|
+
gitToplevelMemo.clear();
|
|
180
|
+
}
|
|
181
|
+
const gitToplevelMemo = new Map();
|
|
182
|
+
/**
|
|
183
|
+
* The root of the working tree `root` belongs to, or `undefined` when it is
|
|
184
|
+
* not inside one (#2007).
|
|
185
|
+
*
|
|
186
|
+
* This is WORKTREE IDENTITY, which path containment cannot supply. A linked
|
|
187
|
+
* worktree lives at a path nested under the main checkout — this repo keeps
|
|
188
|
+
* agent worktrees under `.claude/worktrees/` — yet shares no working files
|
|
189
|
+
* with it. `--show-toplevel` answers which tree a directory really belongs
|
|
190
|
+
* to, so two directories are the same checkout when, and only when, their
|
|
191
|
+
* toplevels match.
|
|
192
|
+
*
|
|
193
|
+
* Memoized beside `isGitWorktree`, and cleared by the same
|
|
194
|
+
* `resetOpaqueMutationState` session boundary, so it cannot become a
|
|
195
|
+
* process-lifetime latch (catalog shape 17). `undefined` is a real cached
|
|
196
|
+
* answer, so the memo is probed with `has`, never by truthiness.
|
|
197
|
+
*/
|
|
198
|
+
export async function resolveGitToplevel(root) {
|
|
199
|
+
const key = normalizeMapKey(path.resolve(root));
|
|
200
|
+
if (gitToplevelMemo.has(key))
|
|
201
|
+
return gitToplevelMemo.get(key);
|
|
202
|
+
const result = await safeSpawnAsync("git", ["rev-parse", "--show-toplevel"], {
|
|
203
|
+
cwd: root,
|
|
204
|
+
timeout: 3000,
|
|
205
|
+
});
|
|
206
|
+
const toplevel = !result.error && result.status === 0 && result.stdout?.trim()
|
|
207
|
+
? result.stdout.trim()
|
|
208
|
+
: undefined;
|
|
209
|
+
gitToplevelMemo.set(key, toplevel);
|
|
210
|
+
return toplevel;
|
|
211
|
+
}
|
|
212
|
+
const UNMERGED_PORCELAIN_STATUSES = new Set([
|
|
213
|
+
"DD",
|
|
214
|
+
"AU",
|
|
215
|
+
"UD",
|
|
216
|
+
"UA",
|
|
217
|
+
"DU",
|
|
218
|
+
"AA",
|
|
219
|
+
"UU",
|
|
220
|
+
]);
|
|
221
|
+
/**
|
|
222
|
+
* Git's Porcelain v1 ordinary-status table, not a Cartesian product. A clean
|
|
223
|
+
* index permits worktree M/T/D, intent-to-add A, and worktree rename/copy R/C.
|
|
224
|
+
* M/T/A/R/C may pair with blank/M/T/D; staged deletion is D<space>, DR or DC.
|
|
225
|
+
* Broaden only with Git docs and a real-status probe.
|
|
226
|
+
*
|
|
227
|
+
* Membership no longer decides pass/fail for a whole command - an absent pair
|
|
228
|
+
* is counted and kept, not fatal (#2060). It still decides which entries the
|
|
229
|
+
* failed-integration filter may treat as "clean index-only incoming", so an
|
|
230
|
+
* unknown pair is never silently classified as someone else's content.
|
|
231
|
+
*/
|
|
232
|
+
const LEGAL_ORDINARY_PORCELAIN_STATUSES = new Set([
|
|
233
|
+
" M",
|
|
234
|
+
" T",
|
|
235
|
+
" D",
|
|
236
|
+
" A",
|
|
237
|
+
" R",
|
|
238
|
+
" C",
|
|
239
|
+
"DR",
|
|
240
|
+
"DC",
|
|
241
|
+
"M ",
|
|
242
|
+
"MM",
|
|
243
|
+
"MT",
|
|
244
|
+
"MD",
|
|
245
|
+
"T ",
|
|
246
|
+
"TM",
|
|
247
|
+
"TT",
|
|
248
|
+
"TD",
|
|
249
|
+
"A ",
|
|
250
|
+
"AM",
|
|
251
|
+
"AT",
|
|
252
|
+
"AD",
|
|
253
|
+
"D ",
|
|
254
|
+
"R ",
|
|
255
|
+
"RM",
|
|
256
|
+
"RT",
|
|
257
|
+
"RD",
|
|
258
|
+
"C ",
|
|
259
|
+
"CM",
|
|
260
|
+
"CT",
|
|
261
|
+
"CD",
|
|
262
|
+
]);
|
|
263
|
+
function isUnmergedStatus(status) {
|
|
264
|
+
return UNMERGED_PORCELAIN_STATUSES.has(status);
|
|
265
|
+
}
|
|
266
|
+
/** An XY pair this module can classify. Unknown pairs are kept, not rejected. */
|
|
267
|
+
function isKnownPorcelainStatus(status) {
|
|
268
|
+
return (status === "??" ||
|
|
269
|
+
status === "!!" ||
|
|
270
|
+
isUnmergedStatus(status) ||
|
|
271
|
+
LEGAL_ORDINARY_PORCELAIN_STATUSES.has(status));
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* The characters Git's short format can put in an XY pair. This is the real
|
|
275
|
+
* fail-closed line (#2060): output whose status field is outside this alphabet,
|
|
276
|
+
* or carries no status at all, is not Porcelain v1 and nothing in it can be
|
|
277
|
+
* trusted. An in-alphabet pair we happen not to have tabulated is a gap in our
|
|
278
|
+
* table, so voiding the whole command's recovery for it would throw away every
|
|
279
|
+
* other path - the read-guard hole this subsystem exists to close.
|
|
280
|
+
*/
|
|
281
|
+
const PORCELAIN_STATUS_CHARS = /^[ MTADRCU?!]{2}$/;
|
|
282
|
+
function isStructurallyValidStatus(status) {
|
|
283
|
+
return PORCELAIN_STATUS_CHARS.test(status) && status.trim() !== "";
|
|
169
284
|
}
|
|
170
285
|
/**
|
|
171
286
|
* Files dirty in the working tree whose mtime falls inside
|
|
172
287
|
* [startedAt - tolerance, now]. Porcelain -z parsing handles renames
|
|
173
|
-
* (the NEW path is reported; the old path token is skipped).
|
|
288
|
+
* (the NEW path is reported; the old path token is skipped before filtering).
|
|
174
289
|
*/
|
|
175
|
-
export async function recoverOpaqueChangesViaGit(root, startedAt) {
|
|
176
|
-
const result = await safeSpawnAsync("git", ["status", "--porcelain", "-z", "--untracked-files=all"], {
|
|
290
|
+
export async function recoverOpaqueChangesViaGit(root, startedAt, options = {}) {
|
|
291
|
+
const result = await safeSpawnAsync("git", ["status", "--porcelain", "-z", "--untracked-files=all"], {
|
|
292
|
+
cwd: root,
|
|
293
|
+
timeout: 5000,
|
|
294
|
+
maxOutputBytes: MAX_GIT_STATUS_OUTPUT_BYTES,
|
|
295
|
+
});
|
|
296
|
+
// #2060: safe-spawn caps stdout before the child finishes. A capped listing
|
|
297
|
+
// is a PREFIX of the truth, so reading it as complete would report every
|
|
298
|
+
// path the cap removed as unchanged.
|
|
299
|
+
//
|
|
300
|
+
// #2100: FIRST, ahead of the git-failed check. Hitting the cap makes
|
|
301
|
+
// safe-spawn SIGTERM the child, so the result also carries an error and a
|
|
302
|
+
// null status — read in the other order every cap kill reported as
|
|
303
|
+
// "git-failed" and this guard could never speak. `truncatedByOutputCap`
|
|
304
|
+
// leaves a timed-out or aborted read to the git-failed branch below, which
|
|
305
|
+
// is the honest answer for those.
|
|
306
|
+
if (truncatedByOutputCap(result)) {
|
|
307
|
+
return {
|
|
308
|
+
verdict: "unknown",
|
|
309
|
+
paths: [],
|
|
310
|
+
unknownReason: "git-status-parse-failed",
|
|
311
|
+
scannedCount: 0,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
177
314
|
if (result.error || (result.status !== 0 && result.status !== null)) {
|
|
178
315
|
return {
|
|
179
316
|
verdict: "unknown",
|
|
@@ -182,12 +319,19 @@ export async function recoverOpaqueChangesViaGit(root, startedAt) {
|
|
|
182
319
|
scannedCount: 0,
|
|
183
320
|
};
|
|
184
321
|
}
|
|
185
|
-
const floorMs = startedAt - OPAQUE_MTIME_TOLERANCE_MS;
|
|
186
322
|
const raw = result.stdout ?? "";
|
|
187
|
-
|
|
188
|
-
|
|
323
|
+
if (raw && !raw.endsWith("\0")) {
|
|
324
|
+
return {
|
|
325
|
+
verdict: "unknown",
|
|
326
|
+
paths: [],
|
|
327
|
+
unknownReason: "git-status-parse-failed",
|
|
328
|
+
scannedCount: 0,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
const entries = [];
|
|
332
|
+
let unknownStatusCount = 0;
|
|
189
333
|
let skipNext = false; // rename's OLD path follows its NEW path
|
|
190
|
-
for (const token of
|
|
334
|
+
for (const token of raw.split("\0")) {
|
|
191
335
|
if (!token)
|
|
192
336
|
continue;
|
|
193
337
|
if (skipNext) {
|
|
@@ -195,26 +339,96 @@ export async function recoverOpaqueChangesViaGit(root, startedAt) {
|
|
|
195
339
|
continue;
|
|
196
340
|
}
|
|
197
341
|
// Each entry: two status chars, one space, then the path.
|
|
198
|
-
if (token.length < 4 || token[2] !== " ")
|
|
199
|
-
|
|
342
|
+
if (token.length < 4 || token[2] !== " ") {
|
|
343
|
+
return {
|
|
344
|
+
verdict: "unknown",
|
|
345
|
+
paths: [],
|
|
346
|
+
unknownReason: "git-status-parse-failed",
|
|
347
|
+
scannedCount: 0,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
200
350
|
const status = token.slice(0, 2);
|
|
201
351
|
const relPath = token.slice(3);
|
|
352
|
+
if (!isStructurallyValidStatus(status) || !relPath) {
|
|
353
|
+
return {
|
|
354
|
+
verdict: "unknown",
|
|
355
|
+
paths: [],
|
|
356
|
+
unknownReason: "git-status-parse-failed",
|
|
357
|
+
scannedCount: 0,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
if (!isKnownPorcelainStatus(status))
|
|
361
|
+
unknownStatusCount += 1;
|
|
202
362
|
if (status.includes("R") || status.includes("C"))
|
|
203
363
|
skipNext = true;
|
|
204
|
-
|
|
364
|
+
entries.push({ status, absPath: path.resolve(root, relPath) });
|
|
365
|
+
}
|
|
366
|
+
if (skipNext) {
|
|
367
|
+
return {
|
|
368
|
+
verdict: "unknown",
|
|
369
|
+
paths: [],
|
|
370
|
+
unknownReason: "git-status-parse-failed",
|
|
371
|
+
scannedCount: 0,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
const hasUnmerged = entries.some((entry) => isUnmergedStatus(entry.status));
|
|
375
|
+
const floorMs = startedAt - OPAQUE_MTIME_TOLERANCE_MS;
|
|
376
|
+
// Kernel "stale" = modified AFTER the window floor - exactly the writes
|
|
377
|
+
// this command may have authored, so exactly the entries that would be
|
|
378
|
+
// dispatched absent any other filtering.
|
|
379
|
+
async function isInWindow(absPath) {
|
|
205
380
|
try {
|
|
206
|
-
const stat = await fs.promises.stat(
|
|
207
|
-
|
|
381
|
+
const stat = await fs.promises.stat(absPath);
|
|
382
|
+
return (stat.isFile() &&
|
|
208
383
|
freshnessFromMtime({ mtimeMs: stat.mtimeMs, referenceMs: floorMs })
|
|
209
|
-
.verdict === "stale")
|
|
210
|
-
// Kernel "stale" = modified AFTER the window floor - exactly the
|
|
211
|
-
// writes this command may have authored.
|
|
212
|
-
paths.push(normalizeMapKey(abs));
|
|
213
|
-
}
|
|
384
|
+
.verdict === "stale");
|
|
214
385
|
}
|
|
215
386
|
catch {
|
|
216
387
|
// Deleted or vanished: deletions are deliberately unreported.
|
|
388
|
+
return false;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
let excludedIncomingCount = 0;
|
|
392
|
+
let candidates = entries;
|
|
393
|
+
if (options.excludeIndexOnlyWhenUnmerged === true && hasUnmerged) {
|
|
394
|
+
// Clean index-only (`XY` with a blank Y) means "staged, worktree matches
|
|
395
|
+
// the index". After a failed integration that content came from the other
|
|
396
|
+
// side, never from the agent: merge, rebase, cherry-pick and revert all
|
|
397
|
+
// REFUSE to start against a dirty index, so no agent-staged file can be
|
|
398
|
+
// sitting here (#2060 F5, probed on git 2.55). An unknown-but-well-formed
|
|
399
|
+
// pair is NOT classified as incoming - capture wins when in doubt.
|
|
400
|
+
// Scope: the refusal argument covers content staged BEFORE the
|
|
401
|
+
// integration started. Content staged mid-call, after the conflict
|
|
402
|
+
// began, is indistinguishable from incoming and is excluded too; the
|
|
403
|
+
// excluded count below is the visibility for that edge.
|
|
404
|
+
// A blank Y already excludes every unmerged pair (all seven are two
|
|
405
|
+
// letters), so this needs no separate unmerged term.
|
|
406
|
+
const kept = [];
|
|
407
|
+
for (const entry of entries) {
|
|
408
|
+
const cleanIndexOnly = entry.status[1] === " " && isKnownPorcelainStatus(entry.status);
|
|
409
|
+
if (!cleanIndexOnly) {
|
|
410
|
+
kept.push(entry);
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
// #2081: only count entries the mtime window would otherwise have
|
|
414
|
+
// dispatched. A long-staged clean-index path outside the window was
|
|
415
|
+
// never going to be reported, so dropping it here is not suppression.
|
|
416
|
+
if (await isInWindow(entry.absPath))
|
|
417
|
+
excludedIncomingCount += 1;
|
|
217
418
|
}
|
|
419
|
+
candidates = kept;
|
|
420
|
+
}
|
|
421
|
+
const paths = [];
|
|
422
|
+
for (const { absPath } of candidates) {
|
|
423
|
+
if (await isInWindow(absPath))
|
|
424
|
+
paths.push(normalizeMapKey(absPath));
|
|
218
425
|
}
|
|
219
|
-
return {
|
|
426
|
+
return {
|
|
427
|
+
verdict: "recovered",
|
|
428
|
+
paths,
|
|
429
|
+
scannedCount: paths.length,
|
|
430
|
+
// Omitted when zero so the common outcome keeps one shape.
|
|
431
|
+
...(excludedIncomingCount > 0 ? { excludedIncomingCount } : {}),
|
|
432
|
+
...(unknownStatusCount > 0 ? { unknownStatusCount } : {}),
|
|
433
|
+
};
|
|
220
434
|
}
|
|
@@ -18,6 +18,7 @@ import * as os from "node:os";
|
|
|
18
18
|
import * as path from "node:path";
|
|
19
19
|
import { classifyProbeFailure, createAvailabilityLatch, isTransientDecision, logAvailabilityDecision, startHostStallSampler, } from "./dispatch/runners/utils/availability-policy.js";
|
|
20
20
|
import { safeSpawnAsync } from "./safe-spawn.js";
|
|
21
|
+
import { createAvailabilityProbeFlight } from "./availability-probe-flight.js";
|
|
21
22
|
/**
|
|
22
23
|
* Fallback preference when nothing is declared (or the declared manager is
|
|
23
24
|
* missing). npm first for maximum compatibility; bun last. A project lockfile
|
|
@@ -29,6 +30,7 @@ const PREFERENCE = [
|
|
|
29
30
|
"yarn",
|
|
30
31
|
"bun",
|
|
31
32
|
];
|
|
33
|
+
const packageManagerProbeFlights = createAvailabilityProbeFlight();
|
|
32
34
|
function onWindows() {
|
|
33
35
|
return process.platform === "win32";
|
|
34
36
|
}
|
|
@@ -94,7 +96,6 @@ const PROBE_TIMEOUT_MS = 5_000;
|
|
|
94
96
|
* a timeout, abort or host stall expires on a cooldown and is re-probed.
|
|
95
97
|
*/
|
|
96
98
|
const availabilityLatches = new Map();
|
|
97
|
-
const inFlightProbes = new Map();
|
|
98
99
|
function getLatch(pm) {
|
|
99
100
|
let latch = availabilityLatches.get(pm);
|
|
100
101
|
if (!latch) {
|
|
@@ -128,6 +129,7 @@ async function probeAvailability(pm) {
|
|
|
128
129
|
latched: true,
|
|
129
130
|
hostStallMs,
|
|
130
131
|
budgetMs: PROBE_TIMEOUT_MS,
|
|
132
|
+
classifiedBy: "probe",
|
|
131
133
|
});
|
|
132
134
|
return true;
|
|
133
135
|
}
|
|
@@ -181,22 +183,8 @@ function isAvailable(pm, onTransient) {
|
|
|
181
183
|
return Promise.resolve(reportIfTransient(memo));
|
|
182
184
|
// A verdict can now expire, so concurrent callers arriving just after a
|
|
183
185
|
// cooldown must share ONE probe rather than each spawning their own.
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
return inFlight.then(reportIfTransient);
|
|
187
|
-
// #1653 review F1: a probe started before a session reset can settle AFTER
|
|
188
|
-
// a later session's own probe for the same manager is already in flight.
|
|
189
|
-
// An unconditional delete-by-key would evict that NEWER entry out from
|
|
190
|
-
// under it, so a third caller in the gap finds nothing in-flight and
|
|
191
|
-
// spawns a duplicate. Only remove the entry if it is still THIS call's
|
|
192
|
-
// promise — the same identity guard `resolveMadge` uses in
|
|
193
|
-
// dependency-checker.ts for the equivalent race.
|
|
194
|
-
const probe = probeAvailability(pm).finally(() => {
|
|
195
|
-
if (inFlightProbes.get(pm) === probe)
|
|
196
|
-
inFlightProbes.delete(pm);
|
|
197
|
-
});
|
|
198
|
-
inFlightProbes.set(pm, probe);
|
|
199
|
-
return probe.then(reportIfTransient);
|
|
186
|
+
const shared = packageManagerProbeFlights.run(`package-manager:${pm}`, () => probeAvailability(pm));
|
|
187
|
+
return shared.promise.then(reportIfTransient);
|
|
200
188
|
}
|
|
201
189
|
/**
|
|
202
190
|
* Clear the process-wide availability cache: pnpm/yarn/bun/npm each sit
|
|
@@ -213,7 +201,7 @@ function isAvailable(pm, onTransient) {
|
|
|
213
201
|
*/
|
|
214
202
|
export function _resetPackageManagerCache() {
|
|
215
203
|
availabilityLatches.clear();
|
|
216
|
-
|
|
204
|
+
packageManagerProbeFlights.clear();
|
|
217
205
|
}
|
|
218
206
|
// ============================================================================
|
|
219
207
|
// RESOLUTION
|
|
@@ -48,7 +48,20 @@ export class PathKeyedMap {
|
|
|
48
48
|
return this;
|
|
49
49
|
}
|
|
50
50
|
delete(path) {
|
|
51
|
-
|
|
51
|
+
if (this.store.delete(this.normalize(path)))
|
|
52
|
+
return true;
|
|
53
|
+
// A filesystem-aware normalizer can change after the keyed file is
|
|
54
|
+
// deleted (for example, real on-disk casing becomes a lowercased missing
|
|
55
|
+
// tail on Windows). The caller may still hold the exact display key it
|
|
56
|
+
// received from this map. Honor that captured identity without deriving a
|
|
57
|
+
// second path form, so deletion remains possible across existence changes.
|
|
58
|
+
for (const [key, entry] of this.store) {
|
|
59
|
+
if (entry.displayPath !== path)
|
|
60
|
+
continue;
|
|
61
|
+
this.store.delete(key);
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
return false;
|
|
52
65
|
}
|
|
53
66
|
clear() {
|
|
54
67
|
this.store.clear();
|
|
@@ -110,6 +110,38 @@ export function normalizeFilePath(filePath) {
|
|
|
110
110
|
}
|
|
111
111
|
}
|
|
112
112
|
}
|
|
113
|
+
/**
|
|
114
|
+
* Normalize a logged `filePath`/`cwd` value, but ONLY when it is already a
|
|
115
|
+
* fully-qualified path (#2219, the #2141 class's sibling loggers). Several
|
|
116
|
+
* NDJSON log-entry types reuse a `filePath`-typed field for non-path
|
|
117
|
+
* sentinels alongside genuine paths — `"<quiet-window>"` in
|
|
118
|
+
* `cascade-logger.ts`, `"<tree-sitter>"` in `tree-sitter-logger.ts`, a shell
|
|
119
|
+
* command or an empty placeholder in `latency-logger.ts`. `normalizeFilePath`
|
|
120
|
+
* resolves a relative-looking string against the CURRENT process cwd (see
|
|
121
|
+
* `resolveNonExisting` above), so running it over one of those sentinels
|
|
122
|
+
* would silently corrupt it into `"<repoRoot>/<quiet-window>"` instead of
|
|
123
|
+
* normalizing it. Only a value that is already fully qualified can be the
|
|
124
|
+
* #2141 mixed-raw/normalized-path defect; anything else is passed through
|
|
125
|
+
* unchanged.
|
|
126
|
+
*
|
|
127
|
+
* #2229 review round 1, F1: this classifier checks BOTH host shapes
|
|
128
|
+
* (`isFullyQualifiedWin32(value) || isFullyQualifiedPosix(value)`), not
|
|
129
|
+
* `isFullyQualified(value)` (host-dispatched on `process.platform`). A
|
|
130
|
+
* Windows-shaped absolute path (`C:\Users\...`) is exactly the log payload
|
|
131
|
+
* this fix exists to normalize, but `isFullyQualified` on Linux CI routes to
|
|
132
|
+
* `isFullyQualifiedPosix`, which rejects it (no leading `/`) — so on Linux
|
|
133
|
+
* the guard silently no-ops for the very inputs the #2141 defect produces,
|
|
134
|
+
* passing the raw backslash form straight through. Checking both shapes
|
|
135
|
+
* makes the classifier's answer for a given STRING independent of which OS
|
|
136
|
+
* is asking; `normalizeFilePath` itself still branches on `process.platform`
|
|
137
|
+
* for how the file is resolved, but whether to normalize at all no longer
|
|
138
|
+
* does (AGENTS.md shape 2, the #1024/#1150 OS-divergence class).
|
|
139
|
+
*/
|
|
140
|
+
export function normalizeLoggedPath(value) {
|
|
141
|
+
return isFullyQualifiedWin32(value) || isFullyQualifiedPosix(value)
|
|
142
|
+
? normalizeFilePath(value)
|
|
143
|
+
: value;
|
|
144
|
+
}
|
|
113
145
|
/**
|
|
114
146
|
* Resolve a non-existing path by finding the nearest existing parent,
|
|
115
147
|
* getting its canonical casing, then appending the non-existent parts lowercased.
|
|
@@ -25,7 +25,6 @@ import { classifyProbeFailure, createAvailabilityChecker, createAvailabilityLatc
|
|
|
25
25
|
import { findDetektConfig } from "./dispatch/runners/detekt.js";
|
|
26
26
|
import { detectFileKind, getFileKindLabel } from "./file-kinds.js";
|
|
27
27
|
import { detectFileChangedAfterCommand, getProjectIgnoreMatcher, isExcludedDirName, } from "./file-utils.js";
|
|
28
|
-
import { isInSpawnTimeoutCooldown } from "./spawn-timeout-cooldown.js";
|
|
29
28
|
import { logLatency } from "./latency-logger.js";
|
|
30
29
|
import { emitLensAnalysisComplete } from "./lens-events.js";
|
|
31
30
|
import { publishFilesTouched } from "./bus-publish.js";
|
|
@@ -338,17 +337,10 @@ async function tryDetektFix(filePath, cwd) {
|
|
|
338
337
|
const absPath = path.resolve(cwd, filePath);
|
|
339
338
|
return detectFileChangedAfterCommand(filePath, cmd, ["--auto-correct", "--input", absPath, "--config", configPath], cwd, [1, 2]);
|
|
340
339
|
}
|
|
341
|
-
// Exported for #1995 cooldown wiring tests: the guard on this lane is
|
|
342
|
-
// one of three mutation-proof surfaces.
|
|
343
340
|
export async function tryMarkdownlintFix(filePath, cwd) {
|
|
344
341
|
const cmd = await resolveToolCommandWithInstallFallback(cwd, "markdownlint");
|
|
345
342
|
if (!cmd)
|
|
346
343
|
return 0;
|
|
347
|
-
// #1995: skip the spawn entirely when the command is cooling down after a
|
|
348
|
-
// timeout — detectFileChangedAfterCommand also self-guards, but a wedged
|
|
349
|
-
// command should not even reach a second budget in the hot loop.
|
|
350
|
-
if (isInSpawnTimeoutCooldown(cmd))
|
|
351
|
-
return 0;
|
|
352
344
|
// Shared config-args seam (#1247): the lint runner consumes the same
|
|
353
345
|
// builder, so the bare --fix here can never fall back to markdownlint's
|
|
354
346
|
// default all-rules-on config again (the whole-file CHANGELOG/AGENTS
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process singletons — state that must exist ONCE PER PROCESS, not once per
|
|
3
|
+
* module evaluation (#2146).
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS MODULE EXISTS. pi evaluates the pi-lens module graph more than once
|
|
6
|
+
* in a single process: dogfood pass 3 measured one pid emitting `host_boot`
|
|
7
|
+
* nine times, another four times. Source and compiled entries load through
|
|
8
|
+
* separate module graphs, in-process subagent sessions re-enter the extension
|
|
9
|
+
* loader, and vitest re-evaluates modules on `vi.resetModules()`. Every
|
|
10
|
+
* module-scope `let` therefore exists N times, and any state whose CORRECTNESS
|
|
11
|
+
* depends on being the process's only copy silently breaks:
|
|
12
|
+
*
|
|
13
|
+
* - `session-lifecycle.ts`'s registered primary: evaluation 2 starts with an
|
|
14
|
+
* empty registration, classifies a subagent temp root as `primary`, and runs
|
|
15
|
+
* the full session_start battery the #473/#2129/#2133 guard exists to
|
|
16
|
+
* decline. Measured cost: three identical word-index full rebuilds in one
|
|
17
|
+
* 90s burst, 240.8s of CPU for one index.
|
|
18
|
+
* - `instance-registry.ts`'s mutation tail: N tails are N concurrent
|
|
19
|
+
* read-modify-write cycles over one `instances.json`. Measured: three
|
|
20
|
+
* `instance-registry-corrupt` records in a 9s window, with two project roots
|
|
21
|
+
* and one live instance lost from the file.
|
|
22
|
+
*
|
|
23
|
+
* PRECEDENT, both in-repo and both cited rather than invented:
|
|
24
|
+
* - `clients/runtime-session.ts:1826` hangs `__piLensFirstSessionDone` /
|
|
25
|
+
* `__piLensWarmupScheduled` on `globalThis` for exactly this reason.
|
|
26
|
+
* - `clients/ndjson-logger.ts:125` does the versioned form: a
|
|
27
|
+
* `Symbol.for()` key, a schema string, a version number, and an explicit
|
|
28
|
+
* adopt-or-decline decision for a state written by a different build.
|
|
29
|
+
*
|
|
30
|
+
* This module generalizes the second one so each state family gets the same
|
|
31
|
+
* treatment without re-deriving the protocol per site.
|
|
32
|
+
*
|
|
33
|
+
* WHAT BELONGS HERE. Only state that is WRONG when duplicated: process-wide
|
|
34
|
+
* registrations, serialization points, and once-per-process latches. A memo or
|
|
35
|
+
* cache that re-derives the same answer from a stable source (an env read, a
|
|
36
|
+
* host probe) is merely wasteful when duplicated, not wrong, and stays at
|
|
37
|
+
* module scope — see the class-sweep table in the #2146 PR body for the
|
|
38
|
+
* per-family verdicts.
|
|
39
|
+
*
|
|
40
|
+
* VERSIONING AND THE OLDER-SHAPE FALLBACK. Two builds can meet in one process:
|
|
41
|
+
* a stale `dist/` graph and a fresh source graph, or an extension reload after
|
|
42
|
+
* an upgrade. Each family carries its own `version`. The rule is
|
|
43
|
+
* adopt-if-compatible, reset otherwise:
|
|
44
|
+
*
|
|
45
|
+
* - Same schema and same version -> ADOPT the existing value. This is the
|
|
46
|
+
* common case and the whole point of the module.
|
|
47
|
+
* - Anything else (missing schema, different schema, older version, NEWER
|
|
48
|
+
* version) -> do NOT adopt. A shape this build cannot read is not safely
|
|
49
|
+
* readable in either direction, so guessing is worse than starting clean.
|
|
50
|
+
* The cell is replaced with a fresh value from `create()` and ONE bounded
|
|
51
|
+
* `process-singleton-reset` entry is logged per family, so a nine-evaluation
|
|
52
|
+
* process still reports at most one row per family. Behavior after a reset is
|
|
53
|
+
* exactly today's module-scope behavior for that family, which is the
|
|
54
|
+
* fail-safe direction.
|
|
55
|
+
*
|
|
56
|
+
* The container key itself is versioned (`SINGLETON_HOST_KEY`). A future change
|
|
57
|
+
* to the CONTAINER shape bumps the key, so an old container is simply not
|
|
58
|
+
* found rather than mis-read.
|
|
59
|
+
*/
|
|
60
|
+
/**
|
|
61
|
+
* STATIC IMPORTS: none, deliberately. This module is a dependency leaf.
|
|
62
|
+
*
|
|
63
|
+
* It cannot import the degradation ledger. `instance-registry.ts` imports this
|
|
64
|
+
* module, and the ledger reaches `instance-reaper.ts` (through `extension-log`
|
|
65
|
+
* -> `file-utils` -> `git-tracked-ignore` -> `safe-spawn` -> `resource-sampler`),
|
|
66
|
+
* and `instance-reaper` imports the registry back. Any edge from here to the
|
|
67
|
+
* ledger closes a `no-client-cycles` cycle, which CI's dependency-boundaries
|
|
68
|
+
* lane rejects. A dynamic import does not help: the rule excludes
|
|
69
|
+
* `dynamic-import` only on the edge it evaluates, not on the intermediate edges
|
|
70
|
+
* of the cycle it walks.
|
|
71
|
+
*
|
|
72
|
+
* So the direction is inverted, exactly as `ndjson-logger.ts` already does for
|
|
73
|
+
* its own sink-write failures: this module keeps a bounded reset log, and
|
|
74
|
+
* `degradation-ledger.ts` PULLS it at read time through
|
|
75
|
+
* {@link getProcessSingletonResets} and folds it into `getDegradationSummary()`.
|
|
76
|
+
* The record still reaches `pilens_health`, with no import from here.
|
|
77
|
+
*/
|
|
78
|
+
/** Bump only when the CONTAINER shape changes, never for a family's shape. */
|
|
79
|
+
const SINGLETON_HOST_KEY = Symbol.for("pi-lens.process-singletons.v1");
|
|
80
|
+
const SINGLETON_SCHEMA = "pi-lens.process-singletons";
|
|
81
|
+
/** Degradation kind emitted when an incompatible cell is discarded. */
|
|
82
|
+
export const PROCESS_SINGLETON_RESET_KIND = "process-singleton-reset";
|
|
83
|
+
const singletonHost = globalThis;
|
|
84
|
+
/**
|
|
85
|
+
* The process's one container. Deliberately created on first read rather than
|
|
86
|
+
* at module scope of a single graph: whichever evaluation runs first wins, and
|
|
87
|
+
* every later evaluation adopts it.
|
|
88
|
+
*/
|
|
89
|
+
function container() {
|
|
90
|
+
const existing = singletonHost[SINGLETON_HOST_KEY];
|
|
91
|
+
if (existing instanceof Map)
|
|
92
|
+
return existing;
|
|
93
|
+
const fresh = new Map();
|
|
94
|
+
singletonHost[SINGLETON_HOST_KEY] = fresh;
|
|
95
|
+
return fresh;
|
|
96
|
+
}
|
|
97
|
+
function isAdoptable(cell, version) {
|
|
98
|
+
if (!cell || typeof cell !== "object")
|
|
99
|
+
return false;
|
|
100
|
+
const candidate = cell;
|
|
101
|
+
return (candidate.schema === SINGLETON_SCHEMA &&
|
|
102
|
+
candidate.version === version &&
|
|
103
|
+
candidate.value !== undefined);
|
|
104
|
+
}
|
|
105
|
+
/** One bounded reset log per process, capped so a pathological build pair
|
|
106
|
+
* cannot grow it without limit. */
|
|
107
|
+
export const PROCESS_SINGLETON_RESET_LOG_FAMILY = "process-singleton-reset-log";
|
|
108
|
+
const RESET_LOG_FAMILY = PROCESS_SINGLETON_RESET_LOG_FAMILY;
|
|
109
|
+
const RESET_LOG_VERSION = 1;
|
|
110
|
+
const RESET_LOG_CAP = 16;
|
|
111
|
+
function resetLog() {
|
|
112
|
+
return getProcessSingleton(RESET_LOG_FAMILY, RESET_LOG_VERSION, () => ({
|
|
113
|
+
entries: [],
|
|
114
|
+
}));
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Record that an incompatible cell was discarded. Bounded twice: once per
|
|
118
|
+
* family (a nine-evaluation process logs at most one entry per family) and once
|
|
119
|
+
* overall by {@link RESET_LOG_CAP}.
|
|
120
|
+
*/
|
|
121
|
+
function recordIncompatibleCell(family, wantedVersion, found) {
|
|
122
|
+
// The reset log is itself a family, so recording ITS reset would recurse.
|
|
123
|
+
// Dropping that one record is correct: the log it would be written to is the
|
|
124
|
+
// thing being discarded.
|
|
125
|
+
if (family === RESET_LOG_FAMILY)
|
|
126
|
+
return;
|
|
127
|
+
const log = resetLog();
|
|
128
|
+
if (log.entries.some((entry) => entry.family === family))
|
|
129
|
+
return;
|
|
130
|
+
if (log.entries.length >= RESET_LOG_CAP)
|
|
131
|
+
return;
|
|
132
|
+
log.entries.push({
|
|
133
|
+
family,
|
|
134
|
+
reason: `incompatible process singleton discarded (found schema=${String(found.schema)} ` +
|
|
135
|
+
`version=${String(found.version)}, this build wants schema=${SINGLETON_SCHEMA} ` +
|
|
136
|
+
`version=${wantedVersion})`,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Read-time view for `degradation-ledger.ts` (see the header). Never performs
|
|
141
|
+
* I/O and never throws, so folding it into a health summary is free.
|
|
142
|
+
*/
|
|
143
|
+
export function getProcessSingletonResets() {
|
|
144
|
+
return resetLog().entries.map((entry) => ({ ...entry }));
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Return the process-wide value for `family`, creating it on the first
|
|
148
|
+
* evaluation that asks and adopting it on every later one.
|
|
149
|
+
*
|
|
150
|
+
* `version` describes the SHAPE of the value `create()` builds. Bump it when a
|
|
151
|
+
* build changes that shape, so a graph carrying the old shape is discarded
|
|
152
|
+
* instead of mis-read (see the module docstring's fallback rules).
|
|
153
|
+
*
|
|
154
|
+
* `create()` runs at most once per process per family, unless an incompatible
|
|
155
|
+
* cell forced a reset.
|
|
156
|
+
*
|
|
157
|
+
* `onIncompatible` is for values that own external resources. It runs before
|
|
158
|
+
* the cell is replaced, so the owner can tear those resources down safely.
|
|
159
|
+
*/
|
|
160
|
+
export function getProcessSingleton(family, version, create, onIncompatible) {
|
|
161
|
+
const cells = container();
|
|
162
|
+
const existing = cells.get(family);
|
|
163
|
+
if (isAdoptable(existing, version))
|
|
164
|
+
return existing.value;
|
|
165
|
+
if (existing !== undefined) {
|
|
166
|
+
// An incompatible cell from another build. One bounded row per family:
|
|
167
|
+
// nine evaluations must not write nine records (AGENTS.md's bounded-record
|
|
168
|
+
// rule; `recordDegradationOnce` keys on kind + subject).
|
|
169
|
+
recordIncompatibleCell(family, version, existing);
|
|
170
|
+
onIncompatible?.(existing.value);
|
|
171
|
+
}
|
|
172
|
+
const value = create();
|
|
173
|
+
cells.set(family, { schema: SINGLETON_SCHEMA, version, value });
|
|
174
|
+
return value;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Test-only: drop the process-wide container so the next
|
|
178
|
+
* {@link getProcessSingleton} call rebuilds every family from `create()`.
|
|
179
|
+
*
|
|
180
|
+
* This clears the GLOBAL state, not a module-local copy — a reset that only
|
|
181
|
+
* cleared module scope would be the very defect this module fixes, and would
|
|
182
|
+
* make every suite that relies on isolation pass vacuously (catalog shape 7).
|
|
183
|
+
*/
|
|
184
|
+
export function _resetProcessSingletonsForTests() {
|
|
185
|
+
singletonHost[SINGLETON_HOST_KEY] = new Map();
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Test-only: install a raw cell so a suite can exercise the older/newer-shape
|
|
189
|
+
* fallback without needing two real builds in one process.
|
|
190
|
+
*/
|
|
191
|
+
export function _seedProcessSingletonCellForTests(family, cell) {
|
|
192
|
+
container().set(family, cell);
|
|
193
|
+
}
|
|
194
|
+
// --- Module-evaluation ordinal (#2146 observability) ---
|
|
195
|
+
const EVALUATION_ORDINAL_FAMILY = "module-evaluation-ordinal";
|
|
196
|
+
const EVALUATION_ORDINAL_VERSION = 1;
|
|
197
|
+
/**
|
|
198
|
+
* How many times this process has evaluated the pi-lens module graph, 1-based.
|
|
199
|
+
*
|
|
200
|
+
* Called once, at module scope of `clients/startup-timing.ts`, so the number it
|
|
201
|
+
* returns is the evaluation count of the graph that contains the extension
|
|
202
|
+
* entry. It is carried on `host_boot.metadata.evaluationOrdinal` so a multi-eval
|
|
203
|
+
* process is greppable from `latency.log` instead of having to be inferred by
|
|
204
|
+
* counting `host_boot` lines per pid — the observability gap #2146 names.
|
|
205
|
+
*/
|
|
206
|
+
export function nextModuleEvaluationOrdinal() {
|
|
207
|
+
const state = getProcessSingleton(EVALUATION_ORDINAL_FAMILY, EVALUATION_ORDINAL_VERSION, () => ({ evaluations: 0 }));
|
|
208
|
+
state.evaluations += 1;
|
|
209
|
+
return state.evaluations;
|
|
210
|
+
}
|