@herbertgao/pi-extensions 2026.8.7 → 2026.8.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. package/README.md +8 -6
  2. package/node_modules/@czottmann/pi-automode/CHANGELOG.md +24 -0
  3. package/node_modules/@czottmann/pi-automode/README.md +78 -112
  4. package/node_modules/@czottmann/pi-automode/docs/GLOSSARY.md +18 -14
  5. package/node_modules/@czottmann/pi-automode/docs/adr/ADR-001-permission-precedence-and-trust-boundaries.md +44 -0
  6. package/node_modules/@czottmann/pi-automode/docs/adr/INDEX.md +5 -0
  7. package/node_modules/@czottmann/pi-automode/docs/automode-classifier-flow.md +198 -104
  8. package/node_modules/@czottmann/pi-automode/docs/configuration.md +155 -0
  9. package/node_modules/@czottmann/pi-automode/docs/defaults.md +55 -14
  10. package/node_modules/@czottmann/pi-automode/docs/diagnostics.md +90 -0
  11. package/node_modules/@czottmann/pi-automode/docs/observability-logging.md +61 -26
  12. package/node_modules/@czottmann/pi-automode/examples/automode.local.json +5 -0
  13. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/classifier.ts +172 -18
  14. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/config.ts +127 -30
  15. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/constants.ts +3 -0
  16. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/extension.ts +273 -31
  17. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/log.ts +60 -5
  18. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/paths.ts +92 -8
  19. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/permissions.ts +203 -13
  20. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/state.ts +1 -0
  21. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/types.ts +11 -0
  22. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/utils.ts +9 -1
  23. package/node_modules/@czottmann/pi-automode/package.json +9 -2
  24. package/node_modules/@czottmann/pi-automode/skills/automode-diagnostics/SKILL.md +63 -0
  25. package/node_modules/@herbertgao/pi-cc-extensions/README.en.md +3 -3
  26. package/node_modules/@herbertgao/pi-cc-extensions/README.md +3 -3
  27. package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/panel.ts +8 -3
  28. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/compact-thinking.ts +63 -15
  29. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/context.ts +56 -29
  30. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/message-display.ts +78 -16
  31. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/hover.ts +13 -0
  32. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/interaction.ts +30 -17
  33. package/node_modules/@herbertgao/pi-cc-extensions/package.json +3 -3
  34. package/node_modules/@herbertgao/pi-handoff/CHANGELOG.md +29 -0
  35. package/node_modules/@herbertgao/pi-handoff/README.md +15 -18
  36. package/node_modules/@herbertgao/pi-handoff/package.json +11 -4
  37. package/node_modules/@herbertgao/pi-handoff/src/complete-text.ts +33 -0
  38. package/node_modules/@herbertgao/pi-handoff/src/index.ts +127 -232
  39. package/node_modules/@herbertgao/pi-handoff/src/session-query.ts +15 -30
  40. package/node_modules/@herbertgao/pi-mermaid-open/CHANGELOG.md +14 -0
  41. package/node_modules/@herbertgao/pi-mermaid-open/README.md +21 -4
  42. package/node_modules/@herbertgao/pi-mermaid-open/herdr-plugin/herdr-plugin.toml +11 -0
  43. package/node_modules/@herbertgao/pi-mermaid-open/herdr-plugin/viewer.mjs +305 -0
  44. package/node_modules/@herbertgao/pi-mermaid-open/package.json +15 -4
  45. package/node_modules/@herbertgao/pi-mermaid-open/src/index.ts +351 -101
  46. package/node_modules/@herbertgao/pi-preferred-thinking/CHANGELOG.md +12 -0
  47. package/node_modules/@herbertgao/pi-preferred-thinking/README.md +30 -14
  48. package/node_modules/@herbertgao/pi-preferred-thinking/package.json +7 -4
  49. package/node_modules/@herbertgao/pi-preferred-thinking/src/index.ts +140 -85
  50. package/node_modules/@herbertgao/pi-recap/CHANGELOG.md +12 -0
  51. package/node_modules/@herbertgao/pi-recap/README.md +5 -3
  52. package/node_modules/@herbertgao/pi-recap/package.json +9 -3
  53. package/node_modules/@herbertgao/pi-recap/src/index.ts +3 -7
  54. package/node_modules/@herbertgao/pi-recap/src/model-picker.ts +6 -19
  55. package/node_modules/@herbertgao/pi-recap/src/models.ts +9 -21
  56. package/node_modules/@herbertgao/pi-recap/src/tui.ts +1 -1
  57. package/node_modules/@herbertgao/pi-rename/CHANGELOG.md +49 -0
  58. package/node_modules/@herbertgao/pi-rename/README.md +13 -11
  59. package/node_modules/@herbertgao/pi-rename/package.json +13 -3
  60. package/node_modules/@herbertgao/pi-rename/src/index.ts +135 -59
  61. package/node_modules/@herbertgao/pi-rename/src/models.ts +5 -13
  62. package/node_modules/@herbertgao/pi-rename/src/naming.ts +2 -6
  63. package/node_modules/@herbertgao/pi-stash/package.json +3 -3
  64. package/node_modules/@herbertgao/pi-subagents/CHANGELOG.md +8 -0
  65. package/node_modules/@herbertgao/pi-subagents/README.md +16 -3
  66. package/node_modules/@herbertgao/pi-subagents/package.json +1 -1
  67. package/node_modules/@herbertgao/pi-subagents/src/cross-extension-rpc.ts +15 -4
  68. package/node_modules/@herbertgao/pi-subagents/src/index.ts +16 -0
  69. package/node_modules/@herbertgao/pi-subagents/src/ui/conversation-viewer.ts +5 -1
  70. package/node_modules/@juicesharp/rpiv-ask-user-question/README.md +4 -4
  71. package/node_modules/@juicesharp/rpiv-ask-user-question/ask-user-question.ts +154 -96
  72. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/hosts.md +3 -0
  73. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/keyboard.md +10 -2
  74. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/localization.md +7 -5
  75. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/tool-schema.md +11 -5
  76. package/node_modules/@juicesharp/rpiv-ask-user-question/locales/en.json +3 -0
  77. package/node_modules/@juicesharp/rpiv-ask-user-question/package.json +2 -2
  78. package/node_modules/@juicesharp/rpiv-ask-user-question/state/key-router.ts +137 -96
  79. package/node_modules/@juicesharp/rpiv-ask-user-question/state/questionnaire-session.ts +44 -32
  80. package/node_modules/@juicesharp/rpiv-ask-user-question/state/selectors/projections.ts +22 -15
  81. package/node_modules/@juicesharp/rpiv-ask-user-question/state/state-reducer.ts +11 -1
  82. package/node_modules/@juicesharp/rpiv-ask-user-question/tool/response-envelope.ts +11 -0
  83. package/node_modules/@juicesharp/rpiv-ask-user-question/tool/types.ts +11 -0
  84. package/node_modules/@juicesharp/rpiv-ask-user-question/view/components/multi-select-view.ts +31 -17
  85. package/node_modules/@juicesharp/rpiv-ask-user-question/view/components/preview/preview-block-renderer.ts +15 -6
  86. package/node_modules/@juicesharp/rpiv-ask-user-question/view/components/wrapping-select.ts +15 -6
  87. package/node_modules/@juicesharp/rpiv-ask-user-question/view/dialog-builder.ts +56 -34
  88. package/node_modules/@juicesharp/rpiv-ask-user-question/view/tab-content-strategy.ts +50 -4
  89. package/node_modules/@narumitw/pi-btw/package.json +2 -2
  90. package/node_modules/@pi-plugins/fast-mode/dist/index.mjs +44 -1856
  91. package/node_modules/@pi-plugins/fast-mode/dist/index.mjs.map +1 -1
  92. package/node_modules/@pi-plugins/fast-mode/package.json +4 -6
  93. package/node_modules/pi-lens/CHANGELOG.md +90 -0
  94. package/node_modules/pi-lens/README.md +106 -0
  95. package/node_modules/pi-lens/dist/clients/bash-file-access.js +97 -6
  96. package/node_modules/pi-lens/dist/clients/blocker-past-eof.js +16 -6
  97. package/node_modules/pi-lens/dist/clients/bounded-telemetry.js +14 -0
  98. package/node_modules/pi-lens/dist/clients/cache-observability.js +353 -76
  99. package/node_modules/pi-lens/dist/clients/degradation-ledger.js +22 -1
  100. package/node_modules/pi-lens/dist/clients/demoted-finding-render.js +155 -0
  101. package/node_modules/pi-lens/dist/clients/deps/pi-tui.js +8 -2
  102. package/node_modules/pi-lens/dist/clients/deps/typebox.js +8 -3
  103. package/node_modules/pi-lens/dist/clients/dispatch/runners/detekt.js +9 -6
  104. package/node_modules/pi-lens/dist/clients/dispatch/runners/hadolint.js +9 -7
  105. package/node_modules/pi-lens/dist/clients/dispatch/runners/htmlhint.js +8 -6
  106. package/node_modules/pi-lens/dist/clients/dispatch/runners/markdownlint.js +8 -10
  107. package/node_modules/pi-lens/dist/clients/dispatch/runners/mypy.js +10 -10
  108. package/node_modules/pi-lens/dist/clients/dispatch/runners/oxlint.js +20 -5
  109. package/node_modules/pi-lens/dist/clients/dispatch/runners/phpstan.js +44 -11
  110. package/node_modules/pi-lens/dist/clients/dispatch/runners/spellcheck.js +10 -15
  111. package/node_modules/pi-lens/dist/clients/dispatch/runners/sqlfluff.js +19 -11
  112. package/node_modules/pi-lens/dist/clients/dispatch/runners/stylelint.js +35 -13
  113. package/node_modules/pi-lens/dist/clients/dispatch/runners/swiftlint.js +8 -8
  114. package/node_modules/pi-lens/dist/clients/dispatch/runners/taplo.js +110 -23
  115. package/node_modules/pi-lens/dist/clients/dispatch/runners/tflint.js +12 -7
  116. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/tool-failure.js +117 -1
  117. package/node_modules/pi-lens/dist/clients/dispatch/runners/vale.js +16 -14
  118. package/node_modules/pi-lens/dist/clients/dispatch/runners/yamllint.js +8 -9
  119. package/node_modules/pi-lens/dist/clients/finding-delivery-gate.js +22 -0
  120. package/node_modules/pi-lens/dist/clients/jscpd-client.js +5 -2
  121. package/node_modules/pi-lens/dist/clients/language-profile.js +7 -2
  122. package/node_modules/pi-lens/dist/clients/lsp/cascade-tier.js +27 -2
  123. package/node_modules/pi-lens/dist/clients/lsp/client.js +144 -5
  124. package/node_modules/pi-lens/dist/clients/lsp/index.js +151 -6
  125. package/node_modules/pi-lens/dist/clients/module-report.js +14 -1
  126. package/node_modules/pi-lens/dist/clients/ndjson-logger.js +114 -23
  127. package/node_modules/pi-lens/dist/clients/project-diagnostics/scanner.js +18 -9
  128. package/node_modules/pi-lens/dist/clients/project-report.js +62 -27
  129. package/node_modules/pi-lens/dist/clients/read-guard-logger.js +7 -2
  130. package/node_modules/pi-lens/dist/clients/read-guard.js +88 -19
  131. package/node_modules/pi-lens/dist/clients/review-graph/builder.js +194 -31
  132. package/node_modules/pi-lens/dist/clients/review-graph/revision-drift.js +21 -0
  133. package/node_modules/pi-lens/dist/clients/review-graph/workspace-modules.js +6 -3
  134. package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +44 -0
  135. package/node_modules/pi-lens/dist/clients/runtime-session.js +41 -0
  136. package/node_modules/pi-lens/dist/clients/runtime-turn.js +47 -2
  137. package/node_modules/pi-lens/dist/clients/session-event-guard.js +184 -0
  138. package/node_modules/pi-lens/dist/clients/source-filter.js +6 -2
  139. package/node_modules/pi-lens/dist/clients/startup-scan.js +5 -2
  140. package/node_modules/pi-lens/dist/clients/tree-sitter-logger.js +1 -0
  141. package/node_modules/pi-lens/dist/index.js +1224 -393
  142. package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +10 -3
  143. package/node_modules/pi-lens/package.json +18 -5
  144. package/node_modules/pi-lens/scripts/install-selftest.mjs +99 -11
  145. package/node_modules/pi-lens/scripts/lib/host-provided-deps.mjs +75 -0
  146. package/node_modules/pi-lens/scripts/lib/warm-loader-cache.mjs +285 -0
  147. package/node_modules/pi-lens/scripts/warm-loader-cache.mjs +233 -0
  148. package/node_modules/pi-web-access/CHANGELOG.md +27 -0
  149. package/node_modules/pi-web-access/README.md +8 -7
  150. package/node_modules/pi-web-access/chrome-cookies.ts +96 -20
  151. package/node_modules/pi-web-access/data-uri-sanitize.ts +406 -0
  152. package/node_modules/pi-web-access/extract.ts +12 -2
  153. package/node_modules/pi-web-access/firecrawl.ts +18 -2
  154. package/node_modules/pi-web-access/gemini-search.ts +28 -10
  155. package/node_modules/pi-web-access/github-extract.ts +65 -36
  156. package/node_modules/pi-web-access/index.ts +43 -14
  157. package/node_modules/pi-web-access/openai-search.ts +17 -6
  158. package/node_modules/pi-web-access/package.json +1 -1
  159. package/node_modules/pi-web-access/pdf-extract.ts +18 -5
  160. package/node_modules/pi-web-access/ssrf-protection.ts +12 -2
  161. package/package.json +19 -18
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-plugins/fast-mode",
3
- "version": "0.1.9",
3
+ "version": "0.1.10",
4
4
  "description": "Fast service-tier extension for pi-agent.",
5
5
  "keywords": [
6
6
  "fast",
@@ -40,6 +40,7 @@
40
40
  },
41
41
  "devDependencies": {
42
42
  "@earendil-works/pi-coding-agent": "0.83.0",
43
+ "@earendil-works/pi-tui": "0.83.0",
43
44
  "@types/node": "26.1.2",
44
45
  "oxfmt": "0.62.0",
45
46
  "oxlint": "1.77.0",
@@ -51,17 +52,14 @@
51
52
  "@pi-plugins/tsconfig": "0.0.0"
52
53
  },
53
54
  "peerDependencies": {
54
- "@earendil-works/pi-coding-agent": "*"
55
+ "@earendil-works/pi-coding-agent": "*",
56
+ "@earendil-works/pi-tui": "*"
55
57
  },
56
58
  "pi": {
57
59
  "extensions": [
58
60
  "./dist/index.mjs"
59
61
  ]
60
62
  },
61
- "inlinedDependencies": {
62
- "@earendil-works/pi-tui": "0.83.0",
63
- "marked": "18.0.5"
64
- },
65
63
  "scripts": {
66
64
  "build": "tsdown",
67
65
  "check": "pnpm run lint && pnpm run check-types && pnpm run format:check",
@@ -16,6 +16,96 @@ All notable changes to pi-lens will be documented in this file.
16
16
 
17
17
  ### Security
18
18
 
19
+ ## [4.1.1] - 2026-08-21
20
+
21
+ ### Added
22
+
23
+ - **Prompt-cache misses now say why, and mixed injections split by source (refs #1071)** — a `cache_usage` record carries `interTurnGapMs` and a `cacheMissCause` verdict of `ttl-expired`, `prefix-broke`, `partial-eviction`, or `unknown`, so the dominant cache cost is readable from `latency.log` instead of reconstructed by hand-joining timestamps. The gap is measured to request time, so a slow response is not mistaken for idle time; `gapBasis` names which endpoint produced the value. A `cache_context` record splits a mixed injection payload by contributing source, with per-source message count, characters, bytes, and an estimated token count. Token figures use a documented four-chars-per-token estimate and are never presented as provider-measured. The TTL threshold defaults to 60s and is overridable via `PI_LENS_PROVIDER_CACHE_TTL_MS`.
24
+
25
+ - **Quick-mode session_start now logs which steps it skipped (closes #1911)** — quick mode silently skipped slow tool probes, language profiling, preinstall, startup scans, and the error-debt baseline, with no record either way. `session_start` now emits one bounded `session_start_skipped_steps` latency record naming the skipped step set, so a reader can tell "quick mode correctly skipped these" from "the probes silently never ran".
26
+
27
+ - **Runner parsers are now held to real binary output (refs #1937)** — a parser written from documentation can be wrong in a way no hand-authored test sees, because the test asserts the same imagined shape. Captured real-bytes fixtures now live in `tests/fixtures/runner-output/`, each with a machine-generated provenance header naming the tool, version, and exact argv; a replay suite feeds those bytes through the runner, fails when the parser finds nothing, and fails when the runner's invocation drifts from the argv the fixture recorded. The sweep found four live instances: taplo spawned a `--output=json` flag taplo rejects, stylelint read stdout while stylelint 16+ reports on stderr, phpstan read the error COUNT as if it were the error array and discarded its file-independent findings entirely, and sqlfluff inserted `--dialect` between `--format` and its value so no unconfigured SQL project was ever linted. A scheduled `parser-smoke` lane now runs the tier-1 tools' real binaries over a planted violation, with a pass floor so a run that installed nothing cannot report green.
28
+
29
+ - **A runner that reads nothing out of a failing tool's output now leaves a record (closes #1948)** — when a dispatch runner's tool exits nonzero, prints output, and the runner's parser extracts zero diagnostics, the degradation ledger gets a bounded `runner-parsed-nothing` row naming the tool, the exit status, the output length, and the first output line. Until now that case was byte-for-byte identical to a clean file in the worklog, which is how five parser bugs (vale, taplo, stylelint, phpstan, sqlfluff) reported clean files for months. A genuinely clean run records nothing, so the ledger does not fill up with one row per save. The gate lives in one shared helper, `parseToolRun`, that fourteen runners now share, and a sweep test keeps a new runner from silently opting out through any of the older spawn-outcome primitives.
30
+
31
+ - **Mechanical lint batch: actionlint, PR-title lint, markdownlint, OSV scan (refs #1844)** — a new `lint.yml` workflow dogfoods actionlint against every workflow file, validates that every PR title carries a conventional prefix and an issue reference **in the title itself** (`scripts/check-pr-title.mjs` — a reference living only in the PR body no longer counts, since it never reaches the merge-commit subject), and lints Markdown docs with `markdownlint-cli2` under a repo-tuned config. A separate `osv-scan.yml` runs an advisory weekly `osv-scanner` sweep plus a scan on lockfile-touching PRs, writing results to the job summary.
32
+
33
+ - **Vale prose lint with a vendored Google style base (refs #1844)** — `.vale.ini` at the repo root scopes Vale to `docs/`, `AGENTS.md`, `CLAUDE.md`, `README.md`, `CONTRIBUTING.md`, `.claude/agents/*.md`, and `.changelog/*.md`, using the Google style package vendored under `.vale/styles/Google/` (no `vale sync` at CI time) plus a `pi-lens/` style with three house rules: no `please` in instructions, an em-dash-density check, and a long-sentence check. A new advisory `vale` job in `lint.yml` runs it on every PR. Because the in-product `vale` runner (`clients/dispatch/runners/vale.ts`) activates on `.vale.ini` presence, dogfood sessions now surface Vale findings on doc edits too.
34
+
35
+ - **Husky-managed local git hooks (closes #1804)** — `npm install` now wires
36
+ a pre-commit hook (changelog fragment validation + `npm run lint`) and a
37
+ pre-push hook (build + a capped, full-path-resolved targeted `vitest`
38
+ selection for changed files, never the full suite; degrades to build-only
39
+ past 25 matched test files or a 2-minute shared test-lock wait). Both are
40
+ skippable with `PI_LENS_SKIP_HOOKS` set to any non-empty value, which
41
+ agents and CI should set; humans leave hooks on. Hook install itself is
42
+ skipped for CI and production/consumer installs, and never fails
43
+ `npm install` on error.
44
+
45
+ - **latency.log now says whether the LSP pool reused a client or spawned one (closes #1934)** — `lsp_client_selected` fired 5601 times in a 20.8h dogfood window carrying only `{serverId, candidateCount}`, so nothing in the log said whether the pool served a warm client or paid for a language-server spawn. This is the most expensive cache in pi-lens: a miss costs a process. The only estimate was a cross-record inference against `lsp_launch_candidate_success`, which counts something else, so a regression that halved pool reuse was invisible. The record now carries an `outcome` field with one of three values on the same record and the same denominator. `warm-reuse` means the pool served an already-connected client. `cold-spawn` means this selection waited on a spawn, whether it started that spawn or joined one already in flight, so a session-start burst where many files wait on the same spawn reads as several cold spawns and deflates the apparent reuse rate for that window. `spawn-failure` means a spawn ran and failed. Reuse rate is `warm-reuse / (warm-reuse + cold-spawn)`. A spawn failure is deliberately distinct from a clean decline: no root, a breaker already open, host trust refused, or a binary absent while installs are disabled. Declines emit no outcome record and keep their existing ones, so `lsp_client_unavailable` and the `outcome` field can both describe the same event and are counted separately. `getWarmClientForFile`, the warm-only lookup the cascade quiet window and read expansion call per file, previously returned `undefined` and emitted nothing; it now emits a bounded `lsp_warm_client_missing` record naming the candidate server and root, rising-edge per candidate set with the exact count in the degradation ledger.
46
+
47
+ ### Fixed
48
+
49
+ - **Search credit ignores pipe truncation (closes #1908)** — `grep -C2 pattern
50
+ file | head -1` no longer credits context lines the pipe cut off. The
51
+ read-guard now detects a truncating pipe tail (`head`, `tail`, `sed q`)
52
+ downstream of a line-numbered grep and falls back to match-line-only
53
+ credit, so a later edit to the uncredited context still requires a read.
54
+
55
+ - **Cascade tier-3 registry now resets at session_start (closes #1910)** — the outstanding-touch registry and its sweep-scoped expired/evicted counters (`clients/lsp/cascade-tier.ts`) used to survive a session replacement, so a new session inherited the prior session's outstanding touches and a stray eviction or expiry landed on the next session's first reconcile gauge. `handleSessionStart` now clears both, primary-only, same as every other per-session latch in that reset block.
56
+
57
+ - **Read-guard eviction telemetry is now always on (closes #1913)** — the
58
+ first time a file's read-guard record cap trims in a session, it now emits
59
+ a `read_cap_trimmed` log line (file, evicted count, credit-vs-genuine
60
+ split, raw read count) regardless of `PI_LENS_READ_GUARD_VERBOSE`, so a
61
+ live eviction regression is visible by default instead of only under
62
+ verbose logging. Later trims on the same file keep updating the running
63
+ totals (queryable via `ReadGuard.getTrimStats`, and via the degradation
64
+ ledger's own tally) without flooding the log on every subsequent trim.
65
+
66
+ - **agent_settled no longer crashes with extension_error when the session is replaced mid-run** —
67
+ stale extension ctx reads are guarded and the ambient abort signal is
68
+ cleared (closes #1924; thanks @Pluto-Yt).
69
+
70
+ - **A session event on a replaced session no longer reports a pi-lens handler error (closes #1925)** — pi invalidates a captured extension ctx on `newSession`/`fork`/`switchSession`/`reload`, and an event already queued when that happens still reaches pi-lens carrying the dead ctx. `tool_result`, `turn_start`, `agent_end`, and `turn_end` each read a ctx property before any guard, so the SDK's `assertActive()` error was caught by `ExtensionRunner.emit` and reported as an extension error against pi-lens. That report named the wrong cause, said nothing about which handler was affected, and counted nothing. All five session-event registrations, including `agent_settled` (#1924), now go through one wrapper in `clients/session-event-guard.ts`: it probes the ctx once before dispatch, still classifies a stale error that arrives mid-handler, and records every skip in the degradation ledger under `extension-ctx-stale` plus a bounded `session_event_stale_ctx_skip` row in `latency.log`, keyed by event name. A new registered-or-fail sweep reds any future `pi.on` or `pi.on?.` registration that is neither wrapped nor given a stated reason.
71
+
72
+ - **Git installs start about 750ms faster (refs #1926)** — pi supplies `typebox` and `@earendil-works/pi-tui` from its own runtime, but pi-lens declared both as runtime dependencies. A `git:` install therefore vendored a private second copy of each, and Node evaluated that whole extra module graph every time the extension loaded. Both are now optional peer dependencies, so nothing vendors them. The `PI_TIMING` module import for the dogfood git install drops from 941ms to 147-196ms, and the extensions block drops from 2165ms to 1449ms. One trade-off, stated plainly: because the bare specifiers no longer resolve on disk, pi loads the entry through jiti's transform path instead of a native import, so the FIRST start after an install, an update, or a jiti cache eviction is slower, around 5.6 to 8.2 seconds on the 4MB bundle. Every start after that is the fast one.
73
+
74
+ - **Updating pi-lens no longer costs a five-second first session (refs #1926)** — pi loads the extension through jiti, which transforms the ~4MB `dist/index.js` bundle and caches the result. Every `git:` install or update produced a new bundle, so the next interactive session paid that transform: 4847ms of `module import`, against a 138ms steady state. The `prepare` chain now runs the transform itself, in `scripts/warm-loader-cache.mjs`, writing the same cache entry pi reads. The first session after an update is warm. The step runs last, is best-effort, and never fails an install; set `PI_LENS_SKIP_WARM_CACHE` to skip it. Each run appends one line to `~/.pi-lens/install.log`.
75
+
76
+ - **A session swap that starves `session_start` or `context` is now counted, not misreported as a pi-lens crash (closes #1929)** — both handlers survived a dead extension ctx already, but each logged it as `session_start crashed: …` or `context event error: …` and recorded nothing, so a replacement that kept starving them was invisible in aggregate. Both now run through the shared stale-ctx wrapper and leave one bounded `extension-ctx-stale` record keyed to the event name. `context` uses a new value-returning wrapper variant that states its stale-path answer explicitly: `undefined`, pi's "this extension contributed nothing", so the host keeps its own message list rather than receiving a half-built injection. Seven of twelve `pi.on` registrations are now wrapped, up from five.
77
+
78
+ - **Diagnostics scans stop logging a phantom ast-grep cache-stats record (closes #1935)** — `#1715` already grows the tree-sitter parse cache to span a project scan's file count, so the diagnostics-scan path does not run at the 50-entry interactive default; that part of `#1935` was resolved before this fix landed. What was still broken: every scan logged a second `cache_stats` record under scope `project_diagnostics_ast_grep_scan`, always all-zero, because ast-grep-napi parses through its own native engine and never touches the WASM tree cache that record claimed to measure. That vacuous record is now removed instead of wired to a cache it structurally cannot use; its one real value — ast-grep's own duration and file count, often a scan's most expensive phase — now rides an `astGrep` sub-field on the surviving `project_diagnostics_scan` record instead of disappearing. Added a memory-safety test pinning `treeCacheTotalBytes` at the existing 500-entry scan ceiling (`TREE_CACHE_SCAN_CAPACITY_CEILING`) using realistic, varying per-file sizes: worst case is 6-18MB of resident source bytes at that ceiling, and `TreeCache` enforces no per-entry byte cap of its own, only the entry count. A regression test pins the scanner to never emit the dead ast-grep scope.
79
+
80
+ - **`cache_context` no longer logs an unusable `unknown` prefix pair on nearly every record (closes #1938)** — `prefixObservation` and `firstMessageChange` were computed from a hash of the pre-injection prefix capped at 64 messages / 2,048 characters. Any transcript past that cap set `prefixHashTruncated`, which forced both fields to `"unknown"`. Every real session outgrows the cap within the first few turns, so the pair reported `unknown` on 97.2% and 7.6% of records respectively — cost without signal. This PR removes the whole family that existed only to support that pair: `prefixObservation`, `prefixObservationUnknown`, `prefixBaseline`, `firstMessageChanged`, `firstMessageChange`, `firstMessageHashTruncated`, `beforeFirstMessageHash`, `afterFirstMessageHash`, `beforePrefixHash`, `afterPrefixHash`, `prefixHashTruncated`, `prefixMessageCountTruncated`, `prefixContentHashTruncated` — 13 fields in all. `cache_prefix_break` already tracks first-message stability with an unbounded hash and never truncates; it remains the source of truth for cache-prefix breaks.
81
+
82
+ - **Review-graph Tier-3 build never grew the tree cache past the 50-entry default (closes #1941)** —
83
+ `builder.ts`'s full-project rebuild parses every non-jsts file through the
84
+ shared `TreeSitterClient`'s parse-tree cache but never called
85
+ `ensureTreeCacheCapacity`, the #1715 fix already wired into the
86
+ diagnostics scanner. A project with more than 50 non-jsts files evicted
87
+ and re-parsed files past the 50th on every Tier-3 build. The build now
88
+ grows the cache to its actual per-parse working set before extraction
89
+ starts — the full file list on a cold build, or the checkpoint's remaining
90
+ files on a resumed build, since resumed files are reused from the
91
+ checkpoint graph and never re-parsed.
92
+
93
+ - **A demoted blocker no longer shouts STOP, and it stops re-serving forever (closes #1944)** — the past-EOF gate moved a blocker whose file had shrunk past the cited lines into the advisory channel, but the advisory embedded the blocker body verbatim, so the agent still read "🔴 STOP — 11 issue(s) must be fixed" with line numbers the file no longer had. Nothing retired the record either, so it re-served on every turn end for the rest of the session (measured live at 80+ minutes). A demoted body now drops the STOP banner and the "must be fixed" imperative, renders a dead coordinate as `L<n> (line no longer exists)`, and — when no re-run could ever confirm it — is delivered once and then retired, with the suppression recorded in the degradation ledger under `demoted-finding-retired`. `lens_diagnostics mode=delta` also stops printing 🔴 on a row whose coordinate it just replaced with the stale marker.
94
+
95
+ - **The oxlint runner now surfaces warning-severity findings instead of discarding them (closes #1947)** — oxlint exits 0 whenever nothing at error severity was found, and warning is oxlint's own default severity. The runner treated any exit 0 as "no findings" and returned early, so a real capture of oxlint on an unused variable — a full JSON report, exit 0 — was thrown away. The runner now parses stdout unconditionally and decides on the parsed diagnostic count instead of the exit code: zero diagnostics is still a clean `succeeded`/`none`, one or more is `failed` with `semantic: "warning"` (or `"blocking"` when a diagnostic is error severity), matching the mapping the runner already used for the nonzero-exit case. A captured real-bytes fixture (`tests/fixtures/runner-output/oxlint/warning-exit-zero.captured.json`, oxlint 1.79.0, exit 0) pins the behavior.
96
+
97
+ - **Merge-train warden's `update-branch` kick 403s under branch protection (#1959)** — the workflow's job permissions granted `pull-requests: write` but not `contents: write`. The PUT `pulls/N/update-branch` endpoint has GitHub create a merge commit on the PR branch, which needs write access to repository contents, not just to the pull request object; the inline comment attributing it to `pull-requests: write` was wrong. The warden also now tells apart the two reasons update-branch can 403: a fork-owned head PR records a distinct benign outcome (`update-branch-forbidden-fork`, logged, not a run failure), while an own-branch PR still fails the run loudly, since that case is a real permissions bug like this one.
98
+
99
+ - **The blind review-graph read no longer drops a valid snapshot on a HEAD-only move (closes #1961)** — `getCachedReviewGraph` verified the persisted snapshot's git stamp against the current HEAD, so a plain `git commit` made every reader see "graph: cold" until something rebuilt in-process. Measured stamp lifetime across four workspaces was 737s to 1562s (median), and two of the four were already missing at probe time. The read path now verifies tree IDENTITY, never revision — the same policy the build path's tier-2 load has followed since #300. A snapshot stamped for a different worktree is still dropped, since nothing downstream of this read content-verifies it. A snapshot stamped at a different HEAD is served, and both `project_report`'s trust notes and `module_report`'s warnings say which commit it was built at. That caveat is derived per call from the snapshot's stamped commit and the worktree's HEAD right now, so it never names a commit that has stopped being HEAD and it clears itself when HEAD returns to the stamped one. The mid-build resume checkpoint carried the same defect and got the same fix: it hashes every processed file on resume, so revision equality proved nothing the hashes did not. Both read verdicts now write a bounded `review-graph.log` record (`snapshot_read_dropped`, `snapshot_read_drifted`), rising-edge per workspace off the `review-graph-snapshot-read` ledger kind — the drop used to be silent, which is why it hid for weeks.
100
+
101
+ - **A settled review-graph build no longer suppresses every later build, and `project_report` only claims a retry when one started (closes #1962)** — `buildOrUpdateGraph`'s dedupe cache deleted its entry on rejection only, so a settled promise for a skipped or completed build answered every later call for the same key for the rest of the process. Only the dispatch pipeline's per-invocation `clearGraphCache()` ever removed it, and the background build `project_report` kicks off never goes through the pipeline: four calls over 37s produced one `build_started` record and three "A retry was started." messages with no retry behind them. The entry now lives only while its build is pending, so genuine concurrency still dedupes and a settled build never latches. `project_report` asks the builder whether a build is already in flight before it claims one started, and reports the current attempt rather than replaying the first one as if it were fresh. A `.pi-lens.json` at the repo root raises `reviewGraph.maxFiles` above the derived 1000-file cap this repo trips, so the tool works on this repo at all.
102
+
103
+ - **A language server that dies mid-session now records why, and the liveness probe stops provoking the deaths (closes #1969)** — an ast-grep child closed with `code=1` and empty stderr 14 times in one day. The visible fallout was 19 `lsp_client_skipped_broken` cooldowns and 32 coverage gaps; the cause was recorded nowhere. Two changes. First, an unprompted process `close` now writes an `lsp-server-unexpected-close` degradation-ledger entry naming the server, the exit code, the signal, and whether stderr carried anything. It is written on `close` rather than `exit`, so "stderr was empty" is a fact about the server and not a race with the pipe, and it is gated on `shutdownRequested`, so evictions and ordinary teardown record nothing. Second, the `pingLiveness` round-trip (#1277) no longer hardcodes `workspace/symbol`. It picks a method the server advertised, best first: `workspace/symbol`, then `textDocument/documentSymbol` or `textDocument/hover` on an already-open document, then nothing. ast-grep's tower_lsp backend logged "got a 'workspace/symbol' request, but it is not implemented" on every probe; it advertises `hoverProvider`, so it keeps a real round-trip. When no probe is available the ledger records `lsp-liveness-probe-unsupported`, because liveness derived from process and connection state alone is the weaker verdict and must not pass as the strong one. Also fixed on the same path: `createLSPClient`'s initialize-timeout catch killed the hung child without claiming the kill, so its own teardown reported as a crash. That fabricated the exact `code=1 signal=none stderr=empty` signature this change exists to make trustworthy, and it had been writing a false `lsp_server_unexpected_exit` latency line on every failed handshake since the #615 follow-up.
104
+
105
+ - **Log writer reopens and retries once on a failed write instead of dropping records silently (refs #1970)** — `ndjson-logger.ts` is the shared write-plumbing behind every NDJSON sink (`latency.log`, `extension.log`, `tree-sitter.log`, `cascade.log`, `word-index.log`, `review-graph.log`, and any other `createNdjsonLogger` caller). This hardens the sink against reachable `appendFile` failures (ENOENT after the parent directory is deleted, EBUSY/EPERM under a syncing OneDrive folder or antivirus lock, EMFILE, ENOSPC) — the specific `ERR_STREAM_DESTROYED` reports that motivated this issue were root-caused to a different process's persistent-stream logger, not this sink, but the reachable-failure classes above land here today with no recovery and no observability. A write that throws now gets one reopen-and-retry (re-verify the parent directory, then retry the write once) before it counts as a loss, matching the `pi-analyze#15` shape. An unrecovered write is counted in a per-sink, in-memory tally (`writeFailures`) rather than thrown or silently discarded, and `degradation-ledger.ts` folds that tally into `getDegradationSummary()` at READ time under a new `log-sink-write-failure` kind — so `pilens_health` names the sink and the dropped-write count. The fold happens by reading ndjson-logger's own tally, never by writing a durable ledger row back through the sink that just failed, so a permanently dead sink cannot recurse into an unbounded chain of self-reporting writes. The tally resets alongside the rest of the ledger at `session_start`.
106
+
107
+ - **Warmup and workspace-scan walkers gate by file extension before consulting the ignore matcher, cutting a 31.7s stall to sub-second on repos with large ignored file piles (closes #1974, reported by @0xkite)** — `isIgnored` recompiles minimatch patterns per ancestor directory on every call; a runtime-output directory like `wal/` holding tens of thousands of `.log` files, gitignored only by a file-level `*.log` pattern (so the directory itself is never pruned), forced every one of those files through that cost. Six walkers checked `isIgnored` before their own cheap extension gate, paying the expensive check for files the gate would have dropped anyway: `collectSourceFilesForWarmup` (`language-profile.ts`, the reported 31.7s case), `JscpdClient.hasSourceFilesRecursive` (`jscpd-client.ts`), `classifyEntry` (`source-filter.ts`), `makeSourceCountVisitor` (`startup-scan.ts`), `getModuleSourceFiles` (`review-graph/workspace-modules.ts`), and `collectWorkspaceDiagnosticFiles` (`lsp/index.ts`). All six now run their extension (or LSP-server) gate first; output is unchanged, since both gates still have to pass to keep a file — only the order changed. A repo-wide sweep of all 14 files that call `isIgnored` found `tree-sitter-client.ts` and `tools/lsp-diagnostics.ts` already extension-first, and cleared the remaining per-event/no-ext-gate sites by construction; see the PR body for the full per-site table.
108
+
19
109
  ## [4.1.0] - 2026-08-20
20
110
 
21
111
  ### Added
@@ -34,6 +34,112 @@ pi-lens gives AI coding agents fast, language-aware feedback while they write/ed
34
34
  - MCP server (experimental) so Claude Code or any MCP client can drive the
35
35
  same diagnostics/read-substitute tools pi-lens exposes to pi
36
36
 
37
+ ## Architecture
38
+
39
+ Most lifecycle events enter through one wrapper, which drops and counts events
40
+ that arrive on a replaced session. `tool_call` registers raw: it delegates
41
+ straight to a handler that owns its own total guard. Events fan out into the
42
+ edit-time lane and the LSP lane. Both lanes write into the findings stores.
43
+ Nothing reaches the agent from those stores until a freshness gate or an
44
+ explicit age label clears it.
45
+
46
+ ```mermaid
47
+ flowchart TD
48
+ subgraph host["pi host"]
49
+ HOST["Host events<br/>tool_call, tool_result, turn_start/end,<br/>session_start/shutdown, agent_end, context"]
50
+ WRAP["Stale-ctx wrapper<br/>skips and counts events on a replaced session<br/>tool_result, turn_start, turn_end, agent_end,<br/>agent_settled, session_start, context"]
51
+ end
52
+
53
+ subgraph guards["Guards"]
54
+ RG["Read-guard<br/>blocks edits that lack prior reading"]
55
+ GG["Git-guard<br/>holds commit/push while findings stay unresolved"]
56
+ end
57
+
58
+ subgraph edit["Edit-time lane"]
59
+ PIPE["Post-write pipeline<br/>secrets, format, autofix, sync, lint, tests"]
60
+ PLAN["Dispatch plan<br/>per file kind, per capability group"]
61
+ RUN["Runners<br/>format, lint, types, security, smells, docs"]
62
+ STRUCT["Structural rules<br/>tree-sitter queries and ast-grep"]
63
+ BUS["files-touched bus<br/>tells extensions which paths moved"]
64
+ end
65
+
66
+ subgraph lsp["LSP lane"]
67
+ POOL["Client pool<br/>warm reuse, idle eviction"]
68
+ DIAGS["File and workspace diagnostics"]
69
+ CASC["Impact cascade<br/>tiered wait policy"]
70
+ end
71
+
72
+ STORES["Findings stores<br/>widget state, warning caches, project snapshot"]
73
+
74
+ subgraph gate["Freshness gating"]
75
+ FRESH["Path freshness<br/>mtime vs scan time, past-EOF, dependency drift"]
76
+ DISPO["Dispositions<br/>false-positive, suppress, defer, flagged"]
77
+ LABEL["Explicit age label<br/>for findings no path gate can check"]
78
+ end
79
+
80
+ subgraph deliver["Delivery surfaces"]
81
+ TURN["Turn-end findings injection"]
82
+ WIDGET["Widget and footer tally"]
83
+ TOOLS["lens_diagnostics tool"]
84
+ NUDGE["Agent nudges"]
85
+ end
86
+
87
+ SESSION["Session lifecycle<br/>primary, sequential replacement, concurrent secondary"]
88
+ SINKS["Observability sinks<br/>latency.log, degradation ledger, bounded telemetry,<br/>cache observability, cascade and tree-sitter logs"]
89
+
90
+ HOST --> WRAP
91
+ HOST -->|tool_call, raw| RG
92
+ HOST -->|tool_call, raw| GG
93
+ WRAP -->|session_start| SESSION
94
+ WRAP -->|tool_result| PIPE
95
+ WRAP -->|tool_result, records reads and writes| RG
96
+ SESSION --> POOL
97
+ SESSION --> STORES
98
+ PIPE --> PLAN
99
+ PLAN --> RUN
100
+ PLAN --> STRUCT
101
+ PIPE --> POOL
102
+ PIPE --> BUS
103
+ POOL --> DIAGS
104
+ DIAGS --> CASC
105
+ RUN --> STORES
106
+ STRUCT --> STORES
107
+ DIAGS --> STORES
108
+ CASC --> STORES
109
+ BUS --> NUDGE
110
+ RG -->|read and edit history filter| NUDGE
111
+ STORES --> FRESH
112
+ STORES --> LABEL
113
+ FRESH --> DISPO
114
+ DISPO --> TURN
115
+ DISPO --> WIDGET
116
+ DISPO --> TOOLS
117
+ LABEL --> TURN
118
+ TURN --> GG
119
+ WRAP --> SINKS
120
+ PIPE --> SINKS
121
+ RUN --> SINKS
122
+ STRUCT --> SINKS
123
+ POOL --> SINKS
124
+ CASC --> SINKS
125
+ RG --> SINKS
126
+ GG --> SINKS
127
+ FRESH --> SINKS
128
+ ```
129
+
130
+ Architecture-level view, updated when a lane changes. Per-tool inventories live
131
+ in [features](docs/features.md) and
132
+ [language coverage](docs/language-coverage.md). Today the edit-time lane carries
133
+ 45+ runner modules over 35+ file kinds, and the LSP lane speaks to a dozen-plus
134
+ language servers.
135
+
136
+ The gating box is an abstraction, not a call order. Freshness covers several
137
+ independent mechanisms: path freshness against scan time, past-EOF line checks,
138
+ and forward-import dependency drift. Dispositions are one more filter alongside
139
+ them, not a second stage every finding walks through. Read the box as "a finding
140
+ passes the gates that apply to it", and see `clients/finding-delivery-gate.ts`
141
+ for the per-surface contract.
142
+
37
143
  ## Install
38
144
 
39
145
  ```bash
@@ -45,10 +45,10 @@ export function tokenizeShellCommand(command) {
45
45
  word = "";
46
46
  atTokenStart = true;
47
47
  };
48
- const flushSegment = () => {
48
+ const flushSegment = (terminator) => {
49
49
  flushWord();
50
50
  if (tokens.length > 0 || unsupported)
51
- segments.push({ tokens, unsupported });
51
+ segments.push({ tokens, unsupported, terminator });
52
52
  tokens = [];
53
53
  unsupported = false;
54
54
  atTokenStart = true;
@@ -122,13 +122,18 @@ export function tokenizeShellCommand(command) {
122
122
  }
123
123
  if (ch === ";" || ch === "\n" || ch === "|" || ch === "&") {
124
124
  flushWord();
125
+ let terminator;
125
126
  if (ch === "|" && next === "|")
126
127
  i++;
127
128
  else if (ch === "&" && next === "&")
128
129
  i++;
129
- else if (ch === "|" || ch === "&")
130
+ else if (ch === "|") {
130
131
  unsupported = true;
131
- flushSegment();
132
+ terminator = "pipe";
133
+ }
134
+ else if (ch === "&")
135
+ unsupported = true;
136
+ flushSegment(terminator);
132
137
  continue;
133
138
  }
134
139
  if (ch === "<" || ch === ">") {
@@ -514,6 +519,83 @@ function parseGrepLineWithoutFile(line, file) {
514
519
  return undefined;
515
520
  return { file, startLine: lineNumber, endLine: lineNumber };
516
521
  }
522
+ /**
523
+ * Verbs that DROP printed lines from a piped stream (#1908, review F4): a
524
+ * hit that survives such a tail cannot be trusted to still carry the
525
+ * -A/-B/-C context grep's flags declared, because that context may have
526
+ * been among the dropped lines. `head`/`tail` drop by position (even bare,
527
+ * since both default to a 10-line window); `sed` with a `q` command drops
528
+ * everything after the addressed line; `uniq` drops adjacent duplicate
529
+ * lines, which can include a context line that happens to repeat.
530
+ *
531
+ * The criterion is DROPPING, not "changes grep's output shape" — a review
532
+ * finding (#1913 F4) caught this file previously conflating the two:
533
+ * - `head`/`tail`/`sed q`/`uniq` downstream of a matching grep → some
534
+ * printed lines are genuinely gone, so credit falls back to
535
+ * match-line-only (the same conservative default already used for
536
+ * unparseable commands) rather than trusting the declared flags.
537
+ * - `sort` and other REORDER-ONLY filters keep every line grep printed,
538
+ * just in a different order — a surviving `file:line:` match still
539
+ * carries the full context grep actually printed, so no special case is
540
+ * needed (proven by the "still credits full context through a
541
+ * non-truncating pipe tail" test below).
542
+ * - `wc` and similar filters that REPLACE grep's output entirely (with a
543
+ * count, say) leave no `file:line:`-shaped line at all, so
544
+ * `parseGrepOutputSearchReads` already finds zero matches in the real
545
+ * captured stdout. Also no special case needed, but for the OPPOSITE
546
+ * reason from `sort`: nothing survives to credit, rather than
547
+ * everything surviving intact.
548
+ * - A pass-through filter (`cat`, `grep -v`, ...) between grep and a
549
+ * dropping tail still counts: the walk below follows the whole pipe
550
+ * chain, not just the immediate next segment.
551
+ * - Out of scope, deliberately: `sed -n 'Np'` range-address filtering
552
+ * without `q`, and `sort -u`, also drop lines but aren't in #1908's
553
+ * stated scope — revisit if they recur in the field. Also out of
554
+ * scope: a truncating tail hidden inside a subshell/brace-group
555
+ * (`( ... )`, `{ ...; }`) or reimplemented in `awk`/`perl` — the shared
556
+ * tokenizer this file uses doesn't parse inside those, matching its
557
+ * documented "small conservative shell lexer" scope.
558
+ */
559
+ const LINE_DROPPING_TAIL_VERBS = new Set(["head", "tail", "uniq"]);
560
+ function isSedQuitCommand(args) {
561
+ for (const arg of args) {
562
+ const token = stripQuotes(arg);
563
+ if (token.startsWith("-"))
564
+ continue;
565
+ // A `q` command anchored at the start of the script or after a `;`/
566
+ // newline separator, optionally preceded by a numeric/`$` address
567
+ // (`q`, `1q`, `$q`, `1,3p;q`). Deliberately not a full sed parser.
568
+ if (/(^|[;\n])\s*(\d+|\$)?\s*q\b/.test(token))
569
+ return true;
570
+ }
571
+ return false;
572
+ }
573
+ function isLineDroppingTailCommand(tokens) {
574
+ const verb = path.basename(stripQuotes(tokens[0] ?? ""));
575
+ if (LINE_DROPPING_TAIL_VERBS.has(verb))
576
+ return true;
577
+ if (verb === "sed")
578
+ return isSedQuitCommand(tokens.slice(1));
579
+ return false;
580
+ }
581
+ /**
582
+ * True when the grep segment starting at `index` feeds — directly or through
583
+ * a chain of pipes — into a line-dropping tail (#1908). Only an unquoted `|`
584
+ * counts: a `;`/`&&`/`&`-separated command downstream never receives grep's
585
+ * stdout, so it must not downgrade credit (#1913 F2 — this was previously
586
+ * unverified by any test that could tell a real pipe from mere adjacency).
587
+ */
588
+ function grepPipesIntoTruncatingTail(segments, index) {
589
+ if (segments[index]?.terminator !== "pipe")
590
+ return false;
591
+ for (let j = index + 1; j < segments.length; j++) {
592
+ if (isLineDroppingTailCommand(segments[j].tokens))
593
+ return true;
594
+ if (segments[j].terminator !== "pipe")
595
+ return false;
596
+ }
597
+ return false;
598
+ }
517
599
  function collectGrepCommandFiles(command, cwd) {
518
600
  const files = new Set();
519
601
  let hasLineNumberGrep = false;
@@ -522,7 +604,14 @@ function collectGrepCommandFiles(command, cwd) {
522
604
  // contributing grep printed, so no line is credited that a hit from the
523
605
  // narrowest grep never delivered.
524
606
  let context;
525
- for (const tokens of commandSegments(command)) {
607
+ // #1908: if ANY contributing grep pipes into a truncating tail, fall back
608
+ // to match-line-only credit for the whole command — the parsed hits carry
609
+ // no segment identity, so a per-segment override isn't representable in
610
+ // the aggregate `context` this function returns.
611
+ let truncated = false;
612
+ const segments = tokenizeShellCommand(command);
613
+ for (let i = 0; i < segments.length; i++) {
614
+ const tokens = segments[i].tokens;
526
615
  const verb = path.basename(stripQuotes(tokens[0] ?? ""));
527
616
  if (verb !== "grep" && verb !== "egrep" && verb !== "fgrep")
528
617
  continue;
@@ -539,11 +628,13 @@ function collectGrepCommandFiles(command, cwd) {
539
628
  : segmentContext;
540
629
  for (const file of extractGrepSearchFiles(args, cwd))
541
630
  files.add(file);
631
+ if (grepPipesIntoTruncatingTail(segments, i))
632
+ truncated = true;
542
633
  }
543
634
  return {
544
635
  hasLineNumberGrep,
545
636
  files,
546
- context: context ?? { before: 0, after: 0 },
637
+ context: truncated ? { before: 0, after: 0 } : (context ?? { before: 0, after: 0 }),
547
638
  };
548
639
  }
549
640
  function dedupePushSearchRead(out, seen, loc) {
@@ -56,7 +56,7 @@ export function sweepInlineBlockerPastEof(runtime, cwd, options) {
56
56
  total: 0,
57
57
  checked: 0,
58
58
  demoted: 0,
59
- healed: 0,
59
+ deadLinesByPath: new Map(),
60
60
  };
61
61
  const resync = options?.resync ?? resyncDocumentOnPastEof;
62
62
  let entries;
@@ -101,15 +101,25 @@ export function sweepInlineBlockerPastEof(runtime, cwd, options) {
101
101
  // The record cites any of its lines being past EOF as a whole-record
102
102
  // demotion — one summary string, not one flag per line.
103
103
  const isPastEof = diagnostics.some((d) => d.stale);
104
+ if (isPastEof) {
105
+ // #1944: record WHICH cited lines died, before the transition check
106
+ // below short-circuits. A record demoted on an earlier turn does not
107
+ // transition, yet its dead lines are exactly what the delivery
108
+ // surface must annotate and retire on.
109
+ counts.deadLinesByPath.set(entry.filePath, diagnostics
110
+ .filter((d) => d.stale)
111
+ .map((d) => d.line)
112
+ .filter((line) => typeof line === "number"));
113
+ }
104
114
  const transitioned = runtime.setInlineBlockerPastEofStale(entry.filePath, isPastEof);
105
115
  if (!transitioned)
106
116
  continue;
107
- if (isPastEof) {
117
+ // Only the rising edge is countable here — see `BlockerPastEofCounts`
118
+ // for why the falling edge cannot occur on this store anymore. The
119
+ // call below still un-demotes, so the STORE stays honest even though
120
+ // nothing can observe the transition.
121
+ if (isPastEof)
108
122
  counts.demoted += 1;
109
- }
110
- else {
111
- counts.healed += 1;
112
- }
113
123
  }
114
124
  catch {
115
125
  // Per-entry failure: leave the entry as-is.
@@ -77,12 +77,26 @@ export const BOUNDED_TELEMETRY_PHASES = [
77
77
  "lsp_client_skipped_broken",
78
78
  /** #1743 review: the same skip, for a temporarily unavailable command. */
79
79
  "lsp_client_skipped_unavailable_command",
80
+ /**
81
+ * #1934: `getWarmClientForFile` found no live client for a file that has at
82
+ * least one language server with a resolvable root. Its callers run per
83
+ * file (the cascade quiet window, read expansion, `hasWarmLSP`), so it is
84
+ * rising-edge per candidate (server, root) set, with the ledger holding the
85
+ * exact count.
86
+ */
87
+ "lsp_warm_client_missing",
80
88
  /**
81
89
  * #1723: an event-loop block at or above the floor. Not a degradation, so
82
90
  * no ledger kind; bounded by call cadence (one `turn_end` runs it once per
83
91
  * turn) rather than by an option here.
84
92
  */
85
93
  "loop_block",
94
+ /**
95
+ * #1925: a session event skipped because its ctx was invalidated by a
96
+ * session replacement or reload. A replaced session can drain a whole
97
+ * queue of them, so it is rising-edge per event name.
98
+ */
99
+ "session_event_stale_ctx_skip",
86
100
  ];
87
101
  const REGISTERED_PHASES = new Set(BOUNDED_TELEMETRY_PHASES);
88
102
  /** Report whether a phase name is in the registry (used by the sweep). */