pi-lens includes 49 language server definitions (including four cross-cutting auxiliary scanners that attach alongside the file's language server — Opengrep, ast-grep, zizmor, and typos — see below). LSP is enabled by default; use --no-lsp to disable it for a session. Servers are auto-discovered from PATH, project node_modules, and managed installs. When a server with a managed installer is not installed, pi-lens offers an interactive install prompt; toolchain-bundled servers, such as Lean's lake, are PATH-only, and pi-lens never installs or manages them.
LSP Idle Management: LSP servers shut down after 240 seconds of inactivity (no files modified) to free resources. The timer resets when you resume editing, preventing cold-start penalties during active development.
Warm files: For language servers that index lazily (e.g. clangd), configure warmFiles in .pi-lens/lsp.json to open entry-point files at session start so the server has AST/index context before the first symbol query:
{ "warmFiles": ["src/main.cpp", "src/lib.cpp"] }Agent LSP tools: lens_diagnostics with source=lsp can check one file, a directory, or explicit paths with bounded concurrency. lsp_navigation provides definitions, references, hover, workspace symbols, call hierarchy, rename edits, and findSymbol for filtered document-symbol lookup. Key operations:
rename— renames a symbol across all references;apply: truewrites workspace edits to disk with per-file LSP re-sync.rename_file— LSP-aware file rename: sendsworkspace/willRenameFilesto collect import-path rewrites, applies them, renames the file on disk, and notifies servers viaworkspace/didRenameFiles.apply: falsepreviews the workspace edits without touching the filesystem.capabilities— shows which operations are supported by the active LSP server(s) for a file, read directly from the cachedinitializeresponse (no round-trip).- Symbol column resolution — passing
symbol: "myFunc"instead of an exactcharacterposition resolves the correct column automatically. Usesymbol: "foo#2"for the second occurrence offooon the line.
LSP servers for: TypeScript, Deno, Python (pyright/basedpyright + jedi), Go, Rust, Ruby (ruby-lsp + solargraph), PHP, PowerShell, C# (omnisharp), F#, Java (JDT LS, with Lombok javaagent support when a Lombok jar is available), Kotlin, Lean, Swift, Dart, Lua, C/C++, Zig, Haskell, Elixir, Gleam, Typst (tinymist), Markdown (marksman), OCaml, Clojure, CUE (syntax and parse diagnostics; evaluation errors via the cue-vet auxiliary runner), Terraform, Nix, Bash, Shuck (zsh-aware shell language server), Fish, CMake, Docker, YAML, JSON, HTML, TOML, Prisma, Vue, Svelte, CSS.
Python carries two separate declarations, and pi-lens reads both from the project rather than from whichever interpreter happens to run a checker.
Interpreter — detectPythonEnvironment takes the first directory that holds a Python binary, in this order: UV_PROJECT_ENVIRONMENT (only at a uv project root), a uv workspace's .venv for a declared member, VIRTUAL_ENV, CONDA_PREFIX, the project's .venv, then its venv. pyright and jedi both launch from it. A binary that comes from a project's own environment needs project trust; without it pi-lens falls back to the managed install and then PATH, and records the refusal once per session. A project launch that fails for any other reason also falls through to that fallback.
Syntax level — detectPythonTargetVersion walks up from the file and, in each directory, takes the first readable declaration of ruff target-version (.ruff.toml, then ruff.toml, then pyproject.toml [tool.ruff], the order ruff itself measures), pyright pythonVersion (pyrightconfig.json, which may use comments and trailing commas, then pyproject.toml [tool.pyright]), requires-python from pyproject.toml [project] reduced to its strongest lower bound (>=3.12,>=3.14 is 3.14 in either order), then the first line of .python-version. The nearest directory holding a declaration wins. Each source ends its own walk where its tool stops: ruff at a ruff config (.ruff.toml, ruff.toml, or a pyproject.toml with [tool.ruff], even one that sets no target-version), pyright at pyrightconfig.json or [tool.pyright], requires-python at a pyproject.toml that carries the key, and .python-version at itself. A pyproject.toml that holds none of these is walked past, as ruff and pyright walk past it, so a nested config that owns a source blocks an ancestor's level for that source and nothing else does. Text that names no CPython major/minor — a pypy-3.10 or branch name in .python-version, an empty file, a specifier with no lower bound — is no declaration at all; pi-lens never invents a default.
When the declared level is 3.14 or newer, jedi's compile findings are dropped. jedi compiles with the interpreter it launched, so a 3.14-only construct reports a spurious SyntaxError (PEP 758's unparenthesized except is the common case). The drop is recorded once per session and file, naming the declared level and the file that declared it, and it applies to the fresh probe, its cached replay, lsp_navigation, mode=full, and the post-write runner that delivers the same server's answer. Every one of those surfaces names the rows it withheld on the text the agent reads (the count and the declaring file), including when the file would otherwise read as clean. A call that spans projects with different declarations cites each declaration with its own count. The structured result carries the same details.withheldCompile object (count, declared, and declarations when there is more than one) on lsp_diagnostics, lens_diagnostics and lsp_navigation. Pyright is unaffected: it parses at the level it resolves and stays the authority on type errors.
ruff needs no override. Its pre-check, --fix pass, and lint run all read target-version from the project config, so pi-lens passes no level to any of them, and pre-check and fix cannot disagree about which syntax parses.
Non-goal: pi-lens never installs, selects, or pins a Python interpreter, and never rewrites a project's declaration.
pi-lens auto-detects and runs 35 formatters based on project config:
biome, prettier, oxfmt, ruff, black, sqlfluff, gofmt, rustfmt, zig fmt, dart format, shfmt, nixfmt, mix format, ocamlformat, clang-format, ktlint, ktfmt, rubocop, standardrb, gleam format, typstyle, terraform fmt, terragrunt-hcl, php-cs-fixer, csharpier, fantomas, swiftformat, stylua, ormolu, taplo, google-java-format, cljfmt, cmake-format, cue fmt, psscriptanalyzer-format
Detection rules:
- Config-gated: only runs when project config indicates usage (e.g.
biome.json,.prettierrc,ruff.toml) - Nearest-wins: when multiple formatter configs exist at different directory levels, the one closest to the edited file wins
- Biome-default: for JS/TS files without Prettier or Biome config, Biome is used as the default formatter
- Ruff-default: for Python files without Black config, Ruff format is used when available
Repositories can disable all immediate and deferred auto-format mutations with
format.enabled: false in .pi-lens.json. Likewise,
autofix.enabled: false disables the separate pipeline fixer phase while
keeping formatter detection, lint dispatch, LSP synchronization, and
diagnostics available.
Auto-fix timing depends on the tool. A write (new file, full overwrite,
or a bash-authored write like sed -i/a redirect) still gets pipeline autofix
immediately, in the same tool result; when it changes the file, the result
carries the full authoritative post-fix content (capped at 2 MiB per file, one
shared budget across a multi-file bash write — past that it degrades to a
re-read warning). An edit defers autofix to agent_end, where it joins the
same per-file deferred-mutation queue as deferred formatting — one coalesced
record per file (kinds: {autofix, format}), autofix draining before format
so the final state is formatter-stable. A write immediately followed by an
edit on the same file in the same turn demotes the write's autofix to
deferred too. See clients/pipeline.ts, clients/runtime-tool-result.ts, and
clients/runtime-agent-end.ts.
A tool pi-lens does not name is classified by the SHAPE of its arguments
(clients/mutating-tool.ts). A host or extension edit tool called replace or
insert therefore gets a turn-state entry, a change-log receipt attributed to
the tool itself, and the deferred autofix and format pass — the same chain
edit gets. Its lines are resolved when the anchor is unambiguous (roughly
two-thirds of anchors in practice — clients/hashline-anchor.ts); otherwise
the mutation is recorded whole-file with lines unknown, and the
read-before-edit guard takes its no-line-info arm rather than guessing.
Deferred is the default for any edit-shaped tool pi-lens cannot place, because
formatting between the calls of a multi-step rewrite fights the tool that is
still writing. A tool whose SHAPE is unrecognized too is caught by observation:
pi-lens takes a bounded snapshot of the path that call names — and only that
path, so a change to a neighbouring file is never blamed on it — replays
whatever actually changed through the same chain, then remembers that tool as
mutating for the session, and on disk under the project's data directory once a
second observation confirms it, so later sessions classify it by name with no
snapshot at all. A tool that names no file is caught at agent_settled by an
incremental content check over the files pi-lens has already read, written,
diagnosed or opened on a language server: a rotating window of the set each
turn, reading only the files whose size or timestamp actually moved, so the
check stays affordable as the set grows. A file it has never seen has no
baseline and is therefore not covered, and a file it cannot verify is named
rather than reformatted on a timestamp alone
(clients/observed-mutation.ts).
Bundled fallback lint configs stay conservative. When a project ships no
tool config, the package-owned fallback configs set the rules (config/biome/core.jsonc,
config/ruff/core.toml, config/markdownlint/core.json). The biome fallback
disables useImportType: its safe fix rewrites a value import used only in
type positions into import type, which erases the runtime binding that
experimental decorator metadata (emitDecoratorMetadata) still needs and
breaks decorator-based dependency injection (refs #2385). Every other
recommended rule stays on, and an explicit project biome.json(c) remains
authoritative: if you enable useImportType there, pi-lens does not override
it. The ruff fallback selects no flake8-type-checking (TC) rules and applies
only safe fixes, so it never hoists imports into TYPE_CHECKING blocks.
Deferred formatting (the agent_end default) runs with bounded
concurrency: at most three formatter subprocesses in flight at once, with
results applied in admission order and cooperative yields between files, so a
large batch of queued files can't stall the event loop or the turn-end pass.
--lens-guard (also guard.enabled: true in ~/.pi-lens/config.json) opts
into blocking git commit/git push while unresolved pi-lens blockers exist.
Detection covers normalized wrapper launchers, shell-escaped and
keyword/combined-flag verb forms, and shell substitutions, while literal text
in non-executing contexts remains allowed. Off by default; see
docs/agent-guide.md and docs/settings.md for
the full behavior and honest limits.
pi-lens builds a review graph (file → symbol → dependency) during session and uses it at turn end to render an impact cascade: which files were affected by a change and how diagnostics propagated through the dependency graph. Nodes track kind, language, and export status; edges track contains/imports/calls/references.
knip follows git ignore rules (.gitignore and .git/info/exclude) and nothing else, so linked worktrees that live inside the project (for example .worktrees/*) are walked as project files: on a pi-lens clone with six of them, 83 percent of the project paths knip saw were worktree files, and a scan took 10.9 s cold and 3.1 s warm instead of 2.6 s and 0.6 s. pi-lens drops their issues from the verdict, runs knip in the worktree that owns an edit, and stops waiting for a root whose last two scans both outlasted the turn_end budget (the knip row then reads reason: "scan-exceeds-budget" and the ledger counts turn-end-knip-nested-worktrees). It does not edit your repository: to remove the cost, add /.worktrees/ (or your worktree directory) to the project's .gitignore.
pi-lens enforces a read-before-edit policy on all file writes and edits. Before allowing a write or edit tool call on an existing file, it verifies that the agent has previously read sufficient context:
- Zero-read block — blocks any edit to a file not read in the current session. Agent-created files are exempt: when a
writetool creates a new file, pi-lens registers the written content as a synthetic read, so an immediate follow-upeditis not blocked - File-modified block — blocks if the file changed on disk since the last read (auto-format, external tool, or a previous edit that was then reformatted)
- Out-of-range block — blocks if the edit target lines fall outside the ranges previously read, ensuring the agent cannot modify code it hasn't seen
- Snapshot validation — covered edit ranges are hash-checked against the lines the agent actually saw at read time; stale-range edits are rejected even when range coverage exists. Hash capture covers reads up to 3 000 lines
Coverage is tracked across multiple reads: two reads of lines 1–100 and 101–200 together satisfy a full-file write. Symbol-expanded reads (small reads widened to the enclosing symbol via tree-sitter, labelled with a leading note) count toward coverage at the symbol level. Markdown files generate a warning instead of blocking (edits outside the section-expanded read range are warned, not silently passed). Plain-text (.txt) and log (.log) files remain fully exempt.
Override for a single edit: /lens-allow-edit <path>
Configure behavior with --no-read-guard to disable it entirely, or use the
read-guard configuration to select warn instead of block where supported.
For "tell me about this file" or "show me one function", prefer the
module_report + read_symbol pair over a full read. Together they're
~4× cheaper than reading the whole file (12 k → 4 k tokens on a 42-symbol
file) and the agent gets a navigable outline plus targeted body fetches
instead of a flat blob of source. See
module-report-read-symbol.md
for the full design and token-efficiency numbers.
module_report(filePath, maxRefsPerSymbol?, focus?, view?, blastRadius?, blastRadiusDepth?) —
returns a structured outline: every symbol's name/kind/startLine/endLine/signature
(plus a first-line doc summary when a doc comment is attached — #512),
exported vs internal split (with class/interface members nested under their
container), who-uses-this (usedBy), fanout/complexity risk flags, and a
recommendedReads top-3 ranked by usage + complexity. No per-symbol read
block (#512) — offset/limit are pure derivations of startLine/endLine
on the report's own path, so build a read call as
{path: report.path, offset: startLine, limit: endLine - startLine + 1}.
Cross-file entries (blastRadius.files[].read, usedBy[].file) keep their own
path since they point at a different file. Pass blastRadius: true to also get
the cross-file blast radius — transitive dependents aggregated to ranked
file read args ("if you change this, verify these files"); read-only over
the cached graph, omitted when cold (this replaced the standalone
pilens_impact tool). view: "compact" returns a line-oriented text
rendering (one line per symbol/callback, roughly a quarter of the JSON cost)
instead of JSON — same data, opt-in. Tree-sitter extract + cached review-graph
lookup; never builds the graph, never calls LSP on this path.
semantic.source reports what backed the data (review-graph | none).
read_symbol(filePath, symbol) — returns the verbatim body of one
named symbol plus a one-line header (<kind> <name> <basename>:<startLine>-<endLine>).
Records the read against the read-guard so a follow-up edit anywhere in
that symbol's range passes the read-before-edit check (an outline from
module_report deliberately does NOT — an outline is shape, not body).
When to use which:
- Skim an unfamiliar file →
module_report, thenread_symbolon the one symbol you actually need. ~−60% vs a fullreadfor the common case. - One giant function in a long file → skip
module_report;reada line range instead (orread_symbolif it's named). - Tiny file (≤ ~10 lines, 0 symbols) → just
read;module_report's metadata overhead exceeds the file. - Looking for a textual pattern across files → use
grep(notmodule_report). - Need exact LSP cross-file resolution → use
lsp_navigation({operation: "definition"}).
MCP mirror: pilens_module_report and pilens_read_symbol in the
pi-lens MCP server expose the same shape to Claude Code / any MCP client.
At turn_end, pi-lens writes <project-data-dir>/cache/actionable-warnings.json summarizing fixable warnings introduced by the current turn. This powers the optional conservative autofix at agent_end.
<project-data-dir> is whatever getProjectDataDir(cwd) resolves to: <project>/.pi-lens only when that legacy directory already exists when the process first resolves the root (a .pi-lens created mid-process takes effect at the next process start), otherwise ~/.pi-lens/projects/<project-slug> (or a PILENS_DATA_DIR location). The turn-end advisory points at lens_diagnostics mode=delta first and names the resolved file second, so you never have to work the layout out by hand (#2521).
Report contents:
- Warnings are delta-only by default: only diagnostics in lines touched during the current turn are included. Pass
--lens-actionable-warning-allto report all warnings regardless of location - Each warning carries a stable
aw:<hash>ID derived from file, rule, and message, so suppression state persists across turns in<project-data-dir>/cache/actionable-warning-state.json - Sources: pipeline
fixablediagnostics (always included) and LSP code-action warnings when--lens-actionable-warning-actionsis set - When warnings are present, a concise advisory is injected into the agent context (no blocker language)
Conservative autofix (agent_end):
When actionableWarnings.autoFix.enabled is set in global or project config (or --lens-actionable-warning-autofix), pi-lens applies LSP quickfixes from the report at agent_end. A project can explicitly disable this mutation with actionableWarnings.autoFix.enabled: false without disabling the report. Safety gates:
- Re-fetches code actions from the live LSP server at fix time (stale actions are skipped)
- Skips any warning with zero or multiple eligible actions (ambiguity is not resolved)
- Applies only
edit-kind actions (no command-only or create/delete operations) - Hard cap of 5 fixes per
agent_end - Suppressed warnings are never autofixed
Flags:
--lens-actionable-warnings— enable the turn_end report--lens-actionable-warning-actions— include LSP code-action warnings in the report--lens-actionable-warning-autofix— apply conservative fixes at agent_end--lens-actionable-warning-all— report all warnings, not just delta
pi-lens writes files outside the agent's own tool calls: dispatch autofix (biome/ruff/eslint/stylelint/sqlfluff/rubocop/ktlint/ktfmt/rust-clippy/dart-fix/golangci-lint/detekt/markdownlint/oxlint --fix) mutates the file immediately for a write and at agent_end for a deferred edit (see "Formatters" above); formatter runs (immediate or deferred-at-agent_end) do the same; and the conservative actionable-warnings autofix above applies LSP quickfixes at agent_end the same way. Other extensions in the same session that track file mutations are otherwise blind to those writes — this event, published either way, is how they find out.
pi-lens broadcasts them on pi's shared in-process event bus (pi.events, exposed to every extension via the ExtensionAPI) as a single named event:
event: pilens:files:touched
payload: {
v: 1,
source: "pi-lens",
reason: "autofix" | "format",
paths: string[], // absolute, normalized (forward slashes, canonical casing)
cwd: string, // absolute, normalized
}
One event per logical write batch (not per file) — e.g. a single eslint --fix invocation that touches one file emits one event with paths: [thatFile]; a deferred-format pass across several queued files emits one event listing all of them.
Versioning policy: additive-only. New optional fields may be added under v: 1. A breaking change to an existing field's meaning bumps v. Consumers should ignore unknown fields.
Non-goals: pi-lens does not (yet) consume anyone's bus events, and does not emit for edits the agent makes itself through its own tool calls — the host already knows about those. This is a broadcast-only surface; see #478 for the planned pilens:rpc:* request/response query API that will reuse the same versioning discipline.
Kill switch: PI_LENS_BUS_PUBLISH=0 disables publishing entirely (see docs/environment-variables.md). Publishing is fire-and-forget — a disabled/unavailable/throwing bus never affects the write path's own success or latency.
Deferred formatting has a separate lifecycle on the same pi.events bus:
pilens:format:queued { v: 1, source: "pi-lens", filePath, cwd, tool, kinds,
ownerSessionId?, turnIndex?, batchId? }
pilens:format:start { v: 1, source: "pi-lens", cwd, paths, fileCount, kinds,
ownerSessionId?, turnIndex?, batchId? }
pilens:format:done { v: 1, source: "pi-lens", cwd, paths, fileCount, kinds,
ownerSessionId?, turnIndex?, batchId?, settled? }
Paths and cwd are absolute, normalized strings. queued is emitted when a
file first enters the deferred queue; start is emitted when a non-empty
claimed format batch begins; and done is emitted after all formatters in that
batch and any late-write resyncs have settled. paths: [] on done means the
batch changed no bytes. batchId is process-unique and monotonic across
coordinator generations, so
two same-millisecond reloads cannot share a lifecycle identity. A normal
pilens:format:done has settled: true (or omits the additive field for old
producers). If the formatter remains unsettled after the bounded hook/format
drain, pi-lens emits one terminal pilens:format:done with settled: false
and the still-pending paths, and records one deferred-format-unsettled
degradation for that batch. A later formatter write is resynced by the late
continuation and does not emit a second done event.
The session and turn fields let a listener match the lifecycle to its own
session and turn; consumers must not infer ownership from paths or timing.
These are visibility events, not a flush API. They are fire-and-forget and can be absent when the bus is disabled, unwired, stale, or throws. Replaced-session and aborted drains publish only for work they actually claim and finish; work requeued for its owner is not announced as done by the replacing session. The events are additive v1 schemas and may gain optional fields without a version bump.
Fix provenance (#502): FilesTouchedPayload gained an additive, optional fixes field so a diff/review consumer can distinguish a pi-lens-mechanical hunk from an agent edit:
fixes?: Array<{
path: string, // absolute, normalized
tool: string, // e.g. "prettier", "ruff", "biome", "lsp-quickfix"
ruleId?: string,
kind: "autofix" | "format",
}>
Old consumers that don't know this field ignore it (still frozen-additive, still v: 1). Attribution is best-effort at some call sites: a single autofix batch can run several tools across several changed files without the underlying runners reporting a per-file breakdown, so in that case every tool that fired in the batch is attributed to every file the batch changed (documented per call site in clients/pipeline.ts). The reason: "autofix" | "format" field on the payload continues to work standalone for consumers that only need the coarse signal.
Extends the #482 producer family from "which files changed" to "what pi-lens knows about them" — the second pi.events broadcast surface, so terminal-native diff/review extensions (e.g. interactive diff review, split/unified diff rendering) can render pi-lens's findings as inline annotations in their own views instead of pi-lens owning a review UI.
event: pilens:diagnostics
payload: {
v: 1,
source: "pi-lens",
cwd: string, // absolute, normalized
seq: number, // monotonic per-emission counter (process-lifetime, not persisted)
ts: number, // emission wall-clock time, ms since epoch
files: [{
path: string, // absolute, normalized
diagnostics: [{
ruleId?: string,
severity: "error" | "warning" | "info" | "hint",
line?: number,
col?: number,
message: string,
tool: string,
fixable?: boolean,
}],
truncated?: boolean, // set when this file's diagnostics exceeded the per-event cap
}],
}
Emission seam. One event per write batch, emitted immediately after recordDiagnostics (clients/widget-state.ts) commits the batch's final per-file diagnostic set — i.e. after auto-format, autofix, and dispatch have all run for that write (see the phase order in clients/pipeline.ts). This guarantees the event reflects the LATEST post-batch state, never an intermediate runner result.
CONSUMER CONTRACT — staleness/replace semantics (load-bearing, follow LSP publishDiagnostics conventions):
- Full-replace per file, never a delta. Every event carries the COMPLETE current diagnostic set for each file it mentions. An event mentioning path P replaces everything a consumer previously held for P — never merge/append across events for the same path.
- Empty array = explicitly clean. When a file's diagnostics clear, pi-lens emits
{path, diagnostics: []}for it exactly once, on the transition. Silence never means clean (the same #240 doctrine, applied on the producer side here) — a consumer that stops hearing about a path has learned nothing about its current state. - Monotonic
seq+tsper emission. Out-of-order receipt (including a future disk-tail consumer à la #492) resolves deterministically: higherseqalways wins, lower is discarded. pilens:files:touched(#482) is an invalidation hint, not new data. Between an edit landing (a files:touched event) and the next diagnostics batch for that path, a consumer's previously-held diagnostics for that path are PROVISIONAL — the file changed on disk but hasn't been re-analyzed yet. Consumers that want to avoid rendering stale annotations across that window should treat a files:touched path as "diagnostics pending" until the nextpilens:diagnosticsevent mentions it.
Late-joiners are a non-problem in-process — extensions activate at session_start, before any turn emits — so v1 is push-only (no request/replay).
Caps: at most 12 diagnostics per file per event (MAX_DIAGNOSTICS_PER_FILE_EVENT in clients/diagnostics-publish.ts, aligned with the widget's own per-file storage cap), errors prioritized over other severities when capping; a capped file entry sets truncated: true. File contents are never included inline.
Kill switch: PI_LENS_BUS_PUBLISH=0 (same family as #482 — no new env var).
Versioning policy: additive-only, same discipline as #482.
Before/after content: intentionally omitted from v1 (the size/complexity tradeoff the original issue sketch flagged) — a consumer that needs pre-format text for diff rendering is a follow-up, not part of this event.
Shared schema with #478 (bound, not merged). The PilensDiagnosticsPayload type (clients/diagnostics-publish.ts) is defined once and reused verbatim by #478's future pilens:rpc:diagnostics pull response — push (this event) and pull (#478) are two deliveries of the same shape over the same lens-engine seam. #478 stays separately gated on #449 registry dogfooding.
When the agent reads a small slice of a file (≤ 100 lines), pi-lens expands the read to the full enclosing symbol (function, method, or class) using the tree-sitter AST, or to the enclosing heading section of a Markdown file. The result starts with a [pi-lens: read widened ...] note naming the requested and the returned lines; a limit above 100 reads the exact range. Expansion is off with the read guard (--no-read-guard or readGuard.enabled=false). The agent receives the full symbol as context, and the read guard records symbol-level coverage so edits anywhere within that symbol pass without requiring the agent to have read every line individually. Expansion runs within a 200 ms budget and falls back silently on unsupported file types or parse failures.
Supported: TypeScript, TSX, JavaScript, JSX, Python, Go, Rust, Ruby, Java, Kotlin, Dart, Elixir, C, C++, C#, PHP, Swift, Lua, OCaml, Zig, Bash.
Covers JavaScript/TypeScript, Python, Go, Rust, Ruby, Shell, and CMake. A TypeScript AST-based fact-rule engine extracts function-level metrics and evaluates quality and security rules inline. Blocking rules surface immediately at write time; advisory rules are available via lens_diagnostics mode=full.
ast_grep_search and ast_grep_replace provide AST-aware pattern matching across 40+ languages via the sg CLI. Key capabilities:
- Metavariable captures — named captures (
$VAR,$$$ARGS) appear below each match:$VAR=x $$$ARGS=a,b,c. - Strictness modes —
strictness: "relaxed"ignores optional punctuation (trailing commas, semicolons) that causes zero matches insmartmode. Also supportsast,cst,signature,template. - Pagination —
skip: Noffsets into large result sets; truncated results include a next-page hint. - Stale-preview detection —
ast_grep_replacere-validates the pattern before writing; returns a clear error if files changed since the preview instead of applying against wrong content. ast_grep_searchwithdump=true— dumps the full tree-sitter AST for a source snippet. Use this when a pattern returns zero matches and the correct node kind or field name is unknown.
Structural rules organized by language in rules/tree-sitter-queries/<language>/. Rules marked 🔴 block the agent inline at write time (only for lines in the current edit); others are advisory.
Suppressing a finding: add // pi-lens-ignore: rule-id on the flagged line or the line above (JS/TS), or # pi-lens-ignore: rule-id for Python/Ruby/Shell. Rule ids are comma-separated. A supported separator (--, —, or : ) starts one trailing reason suffix for the whole payload; all remaining text, including commas, stays in the reason and is not parsed as another rule id. This suppresses each listed rule at that location only.
Bring your own rules: drop YAML query files into rules/tree-sitter-queries/<language>/ in your project — pi-lens merges them with the built-ins on session start. The schema, predicates (eq, match, any-of), and inline_tier (blocking | warning | review) are documented in custom-rules.md. A rules/tree-sitter-queries/rule-schema.json JSON Schema is bundled for editor autocomplete via .vscode/settings.json.
Pattern-based structural rules in rules/ast-grep-rules/ across JS, TS, and Python — covers security (eval, hardcoded secrets, insecure randomness, dangerous DOM sinks), correctness (strict equality, constant conditions, duplicate keys), code smells (nested ternaries, long parameter lists, redundant state), and agent stubs (unimplemented bodies, raise NotImplementedError).
Bring your own rules: drop YAML rule files anywhere under rules/ast-grep-rules/rules/ in your project — recursive discovery merges them with the built-ins, and the same id as a built-in overrides it consistently in raw ast-grep/LSP and NAPI. Duplicate IDs within one source layer are blocking configuration errors. The supported schema is documented in custom-rules.md, with a rules/ast-grep-rules/rule-schema.json JSON Schema for editor autocomplete.
Catalog port + playground cross-check: 11 rules in rules/ast-grep-rules/rules/ were ported from the official ast-grep catalog (security, correctness, framework-hygiene), and 184 vendored CWE-mapped rules live in rules/ast-grep-rules/coderabbit/rules/. To cross-validate a rule against the upstream playground, use scripts/playground-verify-rule.mjs (loads the rule into the upstream playground via headless Chrome and reports the match count the upstream engine produces — a second opinion against the local ast-grep binary; see astplayground.md).
Opengrep (an open, login-free fork of Semgrep) runs as a pi-lens auxiliary diagnostic LSP — a cross-cutting, diagnostic-only language server that attaches alongside the file's normal language server (TypeScript, Python, …) and contributes findings on the same on-write diagnostics path. Running it as a warm LSP server compiles its ruleset once per session rather than on every file, so per-file scans cost ~1–2s (vs ~8s for a cold CLI invocation per file). High-signal security findings become blocking; the rest are advisory.
- On by default (it's a registered LSP server) when the
opengrepbinary is available; pi-lens auto-installs it on demand — a single GitHub-release binary, no login, token, or telemetry. Disable with--no-opengrep. - Rules: a repo
.opengrep.yml/.opengrep.yaml(or a legacy.semgrep.yml/.semgrep.yaml, whose format Opengrep consumes natively) is used if present; otherwise it uses the registry rule pack pi-lens refreshed into its own cache (a bounded weekly background download, validated byopengrep --validatebefore it replaces the previous file; pi-lens ships no rules), and only while no pack has been fetched yet does it fall back to Opengrep's login-freeautoCommunity ruleset. Offline with no pack, that fallback is reported as an unconfirmed scan, never a clean one (#4764).
This is the first adopter of pi-lens's auxiliary-LSP capability (role:"auxiliary" servers + clients/dispatch/auxiliary-lsp.ts) — the same path future cross-cutting scanners (spelling, secrets, …) plug into by registration.
Local rules can opt into pi-lens blocking semantics with metadata:
metadata:
pi-lens:
semantic: blocking
defect_class: injection
confidence: highThree external scanners run once per session in the background (not on every write — their inputs change at most daily and the scans are whole-tree). Each is opt-in and auto-installed only when its gate trips; results surface at turn end, with the highest-severity findings treated as blockers and the rest as advisory.
| Scanner | Finds | Opt-in gate | Auto-install |
|---|---|---|---|
| gitleaks | Committed secrets (API keys, tokens, certs) — regex + entropy, language-agnostic | .gitleaks.toml / .gitleaksignore, a gitleaks dep, or a pre-commit hook referencing it |
GitHub release |
| govulncheck | Go module CVEs reachable from the build graph (call-graph filtered) | a go.mod at the analysis root |
go install (needs the Go toolchain) |
| trivy | Dependency CVEs across every ecosystem (npm, PyPI, Maven/Gradle, Go, Cargo, Composer, RubyGems, NuGet, …), hardcoded secrets, and dependency license risk (copyleft/restricted licenses) — all from one trivy fs pass |
trivy.enabled: true in .pi-lens.json and a dependency manifest at the root |
GitHub release |
Secret findings from gitleaks, trivy, and the ast-grep *-hardcoded-secret-* rules are collapsed by location before surfacing: the same credential flagged by several scanners (with different rule ids) is reported once with combined provenance ([gitleaks + trivy + ast-grep]), not two or three times — the duplicate advisory copy is suppressed. This is the dedup contract that lets multiple secret scanners coexist without the triple-report noise.
Trivy requires an explicit opt-in (rather than just a manifest being present) because its first run pulls a 30–200 MB vulnerability database. Enable it per-project — or globally via a ~/.pi-lens.json — and optionally widen severity:
IaC misconfiguration (per-edit, not a session scan). When trivy.enabled is set, pi-lens also runs trivy config as an on-write dispatch runner (alongside hadolint/tflint) over Dockerfiles and Kubernetes manifests (YAML with an apiVersion: + kind: signature) — Trivy's security-policy engine (runs-as-root, no HEALTHCHECK, privileged: true, missing resource limits, …), a different class from hadolint's lint. On Dockerfiles, trivy-config findings that hadolint already reports at the same line are suppressed, so it only adds the security checks hadolint lacks. Compose/CloudFormation are tracked as follow-ups; Terraform is covered by the same runner, and Helm charts are covered by rendered-manifest validation below.
Rendered-manifest validation for Helm charts (opt-in). helm lint checks a chart's source; it cannot see what the chart produces. When a project sets
{ "helm": { "renderValidation": { "enabled": true } } }pi-lens also renders the nearest chart with helm template into a scratch directory under the system temp dir and validates the output:
- a failed render (a missing values key, a nil pointer in a template expression, a dependency declared in
Chart.yamlbut absent fromcharts/) is reported as a blocking finding on the template that failed — charts that passhelm lintand still cannot be installed; - every rendered document must declare
apiVersionandkind, so a conditional that renders a headless fragment is flagged instead of installing as a silent no-op; - when
trivy.enabledis also set,trivy configruns over the rendered manifests, which is the only way Trivy's Kubernetes policy checks can see a chart's real output.
It is off by default because rendering executes chart-authored template code, and the switch alone is not enough: the render also requires host project trust, since .pi-lens.json is a tracked file a cloned repository could ship pre-enabled. The switch is read from the chart's own project root, not the current directory. In untrusted mode the refusal is reported rather than silently skipped. Nothing is written to the chart directory, --dependency-update is never passed, and the scratch directory is removed on every exit path. Findings map back to the source template through helm's own # Source: annotations; rendered line numbers travel in the message, since they do not correspond to template lines. Full OpenAPI schema validation (kubeconform) is not included.
pi-lens ships an MCP (Model Context Protocol) server so Claude Code — or any MCP client — can drive the same diagnostic + read-substitute surface that the pi agent tools expose, without running pi. The server is a second host adapter alongside the pi extension; both call into the same clients/lens-engine.ts seam so a single implementation powers both surfaces.
Why a second host: the pi extension's tools are registered via the host SDK and run on pi's event loop. Claude Code lives in a different process with no SDK access. The MCP server sits in that gap, speaking JSON-RPC over stdio (or a warm Unix socket / Windows named pipe side-channel for the Claude Code PostToolUse hook). It's the easiest way to live-test, debug, and dogfood pi-lens — including running a review loop where Claude commits to pi-lens and re-measures.
18 tools, grouped by lifecycle layer (the same three layers the pi agent hooks use):
| Layer | MCP tools | What they expose |
|---|---|---|
| Per-edit | pilens_analyze, pilens_lsp_navigation, pilens_ast_grep_search, pilens_ast_grep_replace, pilens_module_report, pilens_read_symbol, pilens_read_enclosing |
The fast pipeline (format → autofix → LSP diagnostics → parallel runners) plus the structured read-substitute pair. analyze accepts mode: warm | fresh — warm reuses the server's in-process LSP, fresh forks a worker that loads freshly-built code from disk so the result reflects the latest commit. |
| Per-turn | pilens_turn_end |
Drives the real handleTurnEnd (knip incremental, dep-circular, cascade, tests, actionable+code-quality warnings) — not a re-implementation. Caller-supplied edited files are auto-registered into turn-state via addModifiedRange. |
| Per-session | pilens_session_start |
Drives the real handleSessionStart — full jscpd/knip/madge/govulncheck/gitleaks/trivy scans + complexity baselines + LSP warm. The error-debt baseline is not currently populated by the production session-start path. |
| Project / observability | pilens_project_scan, pilens_project_report, pilens_diagnostics, pilens_health, pilens_latency, pilens_symbol_search, pilens_effective_config |
Cheap project-wide scans, cached diagnostic state, latency telemetry, ranked identifier search (BM25 over the persisted word index — see docs/word-index.md). Cross-file blast radius now lives in pilens_module_report's blastRadius option. pilens_health (and its pi-side /lens-health counterpart) also reports a bounded, process-local degradation ledger — trust refusals, mode suppressions, LSP breaker trips, formatter skips/failures, TypeScript/word-index/review-graph/project-snapshot idle evictions, WASM aborts, and diagnostics-timeout tallies — so silently degraded behavior stays visible instead of vanishing into a log. pilens_effective_config answers “why is X running / why is X not running” from one query — the resolved configuration with the provenance of every setting, and for a file you name, its language plus every LSP server with the reason it was selected or denied and the runners that would dispatch. It reports sources, never values. |
| Lifecycle / loop | pilens_rebuild, pilens_session_end |
pilens_rebuild runs npm run build:dist so pilens_analyze mode=fresh reflects the latest commit. Makes the review loop self-contained: commit → pilens_rebuild → pilens_analyze mode=fresh → pilens_latency. pilens_session_end ends the MCP connection's telemetry session and writes its bounded situational-tool telemetry line; terminal for the connection. |
Honest limits (live-tested, documented in mcp.md):
freshalways cold-spawns the LSP, so it systematically under-reports LSP diagnostics on large TS projects (typescript-language-servermust index the whole project first). The result carries an explicitlsphonesty signal (ran/status/diagnosticCount/durationMs) so a cold0is never read as "clean" — usewarmfor LSP-complete reviews.pilens_analyzeby default surfaces everything (blockingOnly=false); the per-edit fast path in the pi extension is still blocking-first.- The MCP server keeps the LSP warm across calls within its process;
freshis for benchmarking a real cold spawn, not for steady-state usage.
Transport is hand-rolled JSON-RPC over stdio — zero new dependencies. The npm install --omit=dev constraint means even an "optional" SDK weighs down every pi-lens install; ~200 LOC of plain JSON-RPC beats a dep for a tools-only server. A warm Unix-socket / Windows-named-pipe side-channel (clients/mcp/ipc.ts) lets the pi-lens-analyze PostToolUse hook reuse the server's warm LSP without touching the stdio transport.
Install / register in Claude Code:
# Build the bundled dist (or `npm run build` for the in-place dev build)
npm run build:dist
# User-scope registration with auto session-start on connect
claude mcp add --scope user pi-lens \
-e PI_LENS_MCP_AUTO_SESSION=1 \
-- node <repo>/dist/mcp/server.jsHooks (settings.json) close the loop: PostToolUse = per-edit, Stop = per-turn.
{ "hooks": {
"PostToolUse": [
{ "matcher": "Edit|Write",
"hooks": [ { "type": "command", "command": "pi-lens-analyze --hook" } ] } ],
"Stop": [
{ "hooks": [ { "type": "command", "command": "pi-lens-analyze --turn-end", "timeout": 60 } ] } ]
} }The per-edit hook falls back to a cold local analysis when no server is up; the Stop hook is warm-server-only because only the server process owns the session state and pending turn work. It skips with a single stderr line when unavailable. Workspace IPC requests are ordered, so a timed-out PostToolUse client cannot let Stop overtake analysis still running in the server. SubagentStop is deliberately not registered because subagent edits already reach turn-state through PostToolUse.
The full design + tier-by-tier progress (and known limits) lives in docs/mcp.md. Status: experimental — the foundation is solid (transport, warm LSP, lifecycle handlers wired), but the surface is still maturing. Use the pi extension for production agent work; reach for the MCP server for debugging, dogfooding, and direct Claude Code access.