pi-lens 3.8.67 → 3.8.69

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 (74) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/README.md +57 -38
  3. package/dist/clients/agent-nudge.js +262 -0
  4. package/dist/clients/biome-client.js +2 -2
  5. package/dist/clients/bus-publish.js +110 -0
  6. package/dist/clients/deps/ast-grep-napi.js +20 -1
  7. package/dist/clients/deps/pi-tui.js +4 -1
  8. package/dist/clients/deps/typebox.js +5 -2
  9. package/dist/clients/deps/web-tree-sitter.js +22 -1
  10. package/dist/clients/diagnostic-logger.js +2 -2
  11. package/dist/clients/diagnostics-publish.js +180 -0
  12. package/dist/clients/dispatch/dispatcher.js +2 -0
  13. package/dist/clients/dispatch/integration.js +153 -2
  14. package/dist/clients/dispatch/runners/ast-grep-napi.js +58 -13
  15. package/dist/clients/dispatch/runners/yaml-rule-parser.js +68 -19
  16. package/dist/clients/file-utils.js +20 -7
  17. package/dist/clients/installer/index.js +32 -1
  18. package/dist/clients/instance-reaper.js +516 -0
  19. package/dist/clients/instance-registry.js +238 -0
  20. package/dist/clients/jscpd-client.js +2 -2
  21. package/dist/clients/lens-config.js +17 -0
  22. package/dist/clients/lens-engine.js +44 -10
  23. package/dist/clients/lsp/cascade-tier.js +254 -0
  24. package/dist/clients/lsp/client.js +81 -3
  25. package/dist/clients/lsp/index.js +3 -0
  26. package/dist/clients/lsp/launch.js +2 -0
  27. package/dist/clients/lsp/server-strategies.js +71 -0
  28. package/dist/clients/lsp/server.js +119 -6
  29. package/dist/clients/mcp/analyze.js +110 -1
  30. package/dist/clients/module-report.js +163 -18
  31. package/dist/clients/path-utils.js +25 -0
  32. package/dist/clients/persist-debounce.js +63 -0
  33. package/dist/clients/pipeline.js +82 -2
  34. package/dist/clients/project-diagnostics/extractors.js +30 -4
  35. package/dist/clients/project-snapshot.js +7 -2
  36. package/dist/clients/quiet-window.js +168 -0
  37. package/dist/clients/recent-touches.js +233 -0
  38. package/dist/clients/review-graph/builder.js +20 -2
  39. package/dist/clients/runtime-agent-end.js +51 -1
  40. package/dist/clients/runtime-coordinator.js +21 -0
  41. package/dist/clients/runtime-session.js +154 -48
  42. package/dist/clients/runtime-tool-result.js +46 -0
  43. package/dist/clients/runtime-turn.js +9 -0
  44. package/dist/clients/session-lifecycle.js +252 -0
  45. package/dist/clients/sgconfig.js +246 -38
  46. package/dist/clients/slow-fs.js +137 -0
  47. package/dist/clients/source-filter.js +62 -14
  48. package/dist/clients/subagent-mode.js +87 -0
  49. package/dist/clients/tree-sitter-symbol-extractor.js +108 -0
  50. package/dist/clients/tui-fit.js +54 -0
  51. package/dist/clients/turn-summary-render.js +72 -0
  52. package/dist/clients/turn-summary.js +132 -0
  53. package/dist/clients/widget-state.js +27 -30
  54. package/dist/clients/word-index.js +296 -1
  55. package/dist/index.js +62809 -1633
  56. package/dist/mcp/build-staleness.js +123 -0
  57. package/dist/mcp/server.js +377 -43
  58. package/dist/tools/ast-grep-search.js +1 -1
  59. package/dist/tools/lens-diagnostics.js +42 -12
  60. package/dist/tools/lsp-diagnostics.js +117 -4
  61. package/dist/tools/module-report.js +14 -11
  62. package/dist/tools/symbol-search.js +110 -0
  63. package/package.json +3 -2
  64. package/rules/ast-grep-rules/rule-tests/hardcoded-url-js-test.yml +1 -0
  65. package/rules/ast-grep-rules/rule-tests/no-typeof-undefined-js-test.yml +8 -0
  66. package/rules/ast-grep-rules/rule-tests/no-typeof-undefined-test.yml +8 -0
  67. package/rules/ast-grep-rules/rules/hardcoded-url-js.yml +1 -1
  68. package/rules/ast-grep-rules/rules/no-typeof-undefined-js.yml +9 -7
  69. package/rules/ast-grep-rules/rules/no-typeof-undefined.yml +9 -7
  70. package/skills/{ast-grep → pi-lens-ast-grep}/SKILL.md +1 -1
  71. package/skills/{lsp-navigation → pi-lens-lsp-navigation}/SKILL.md +1 -1
  72. package/skills/{write-ast-grep-rule → pi-lens-write-ast-grep-rule}/SKILL.md +1 -1
  73. package/skills/{write-tree-sitter-rule → pi-lens-write-tree-sitter-rule}/SKILL.md +1 -1
  74. package/dist/clients/tree-sitter-fixer.js +0 -127
@@ -0,0 +1,262 @@
1
+ import { logLatency } from "./latency-logger.js";
2
+ import { normalizeMapKey } from "./path-utils.js";
3
+ const BUS_FILES_TOUCHED_EVENT = "pilens:files:touched";
4
+ const MAX_NAMES_SHOWN = 5;
5
+ // Module-level accumulator: one process/session, so a plain map keyed via
6
+ // normalizeMapKey (house style — every map/set key in this module MUST go
7
+ // through it; a hand-rolled replace() is the exact trap that cost two red CI
8
+ // rounds on #458's reconcile tests, PR #491) is sufficient. Cleared ONLY
9
+ // inside consumeAgentNudge() (i.e. only at actual injection into a `context`
10
+ // call) — deliberately NOT tied to turn_start/agent_end/agent_settled, so
11
+ // entries accumulated during run A's turn_end survive until run B's first
12
+ // `context` call in the same session (the cross-run `git status` case).
13
+ const _touched = new Map();
14
+ // Count of bus-reported paths dropped by the relevance filter (file never
15
+ // read/edited this session) since the last consume — drained alongside the
16
+ // accumulator so the `agent_nudge` phase can report how much the filter
17
+ // actually suppresses, which is the metric that validates (or indicts) the
18
+ // "only nudge for files the session saw" rule.
19
+ let _relevanceFilteredCount = 0;
20
+ /** Test-only: clear accumulator state between test files/cases. */
21
+ export function _resetAgentNudgeForTests() {
22
+ _touched.clear();
23
+ _relevanceFilteredCount = 0;
24
+ _enabledCache = undefined;
25
+ }
26
+ // --- Kill switch (lazy, memoized — house style per clients/quiet-window.ts) ---
27
+ let _enabledCache;
28
+ /** `PI_LENS_AGENT_NUDGE=0` disables accumulation and injection outright. */
29
+ export function isAgentNudgeEnabled() {
30
+ if (_enabledCache === undefined) {
31
+ _enabledCache = process.env.PI_LENS_AGENT_NUDGE !== "0";
32
+ }
33
+ return _enabledCache;
34
+ }
35
+ function isValidPayload(data) {
36
+ if (!data || typeof data !== "object")
37
+ return false;
38
+ const p = data;
39
+ return (p.v === 1 &&
40
+ p.source === "pi-lens" &&
41
+ (p.reason === "autofix" || p.reason === "format") &&
42
+ Array.isArray(p.paths));
43
+ }
44
+ /**
45
+ * Record a `pilens:files:touched` event into the accumulator, filtered to
46
+ * files the session has actually read or edited (read-guard is the source of
47
+ * truth — `getReadHistory`/`getEditHistory` both key internally via
48
+ * `normalizeFilePath`, so either separator form on the incoming bus payload
49
+ * or the guard's stored records resolves to the same record regardless of
50
+ * which form was recorded first).
51
+ */
52
+ function recordTouchedEvent(payload, getReadGuard) {
53
+ const readGuard = getReadGuard();
54
+ if (!readGuard)
55
+ return;
56
+ for (const rawPath of payload.paths) {
57
+ const isRelevant = readGuard.getReadHistory(rawPath).length > 0 ||
58
+ readGuard.getEditHistory(rawPath).length > 0;
59
+ if (!isRelevant) {
60
+ _relevanceFilteredCount++;
61
+ continue;
62
+ }
63
+ const mapKey = normalizeMapKey(rawPath);
64
+ const existing = _touched.get(mapKey);
65
+ if (existing) {
66
+ existing.reasons.add(payload.reason);
67
+ // "local" is sticky — see AccumulatedFileOrigin doc. A file already
68
+ // recorded via the cross-process feed, now also reported by this
69
+ // session's own bus, upgrades to "local".
70
+ existing.origin = "local";
71
+ }
72
+ else {
73
+ _touched.set(mapKey, {
74
+ displayPath: rawPath,
75
+ reasons: new Set([payload.reason]),
76
+ origin: "local",
77
+ });
78
+ }
79
+ }
80
+ }
81
+ /**
82
+ * Feed entries read from the cross-process `recent-touches.json` record
83
+ * (#492, clients/recent-touches.ts) into the SAME accumulator #485 uses for
84
+ * in-process bus events — one accumulator, one `consumeAgentNudge` call, one
85
+ * batched context message regardless of how many files came from which
86
+ * channel.
87
+ *
88
+ * Relevance filtering differs by call site (#492 point 6) and is therefore
89
+ * the CALLER's responsibility, not this function's:
90
+ * - parent at turn_start: read-guard history FIRST (a parent usually has
91
+ * one), falling back to recency-only for files it hasn't read (a parent
92
+ * about to `git commit` needs attribution even for unread files);
93
+ * - child at session_start: recency + file-existence only (no read
94
+ * history exists yet this early).
95
+ * `readCrossProcessTouchesForTurnStart` / `...ForSessionStart` in
96
+ * recent-touches.ts already apply their own recency/existence/self-pid
97
+ * filtering before entries reach here — this function does not re-derive
98
+ * relevance, it only merges into the accumulator and marks provenance.
99
+ *
100
+ * Self-exclusion (an entry this process itself published never nudges
101
+ * itself) and path+ts dedup across repeated reads of the record are both
102
+ * handled upstream in recent-touches.ts (pid filter + last-consumed cursor),
103
+ * so entries reaching this function are already the "new, foreign" set.
104
+ */
105
+ export function recordCrossProcessTouches(entries) {
106
+ if (!isAgentNudgeEnabled())
107
+ return;
108
+ for (const entry of entries) {
109
+ const mapKey = normalizeMapKey(entry.path);
110
+ const existing = _touched.get(mapKey);
111
+ if (existing) {
112
+ existing.reasons.add(entry.reason);
113
+ // "local" is sticky (see AccumulatedFileOrigin) — never downgrade an
114
+ // already-local entry back to cross-process.
115
+ }
116
+ else {
117
+ _touched.set(mapKey, {
118
+ displayPath: entry.path,
119
+ reasons: new Set([entry.reason]),
120
+ origin: "cross-process",
121
+ });
122
+ }
123
+ }
124
+ }
125
+ /**
126
+ * Subscribe to `pilens:files:touched` on pi's shared event bus. Called once
127
+ * at extension factory time from index.ts, mirroring `wireBusEmitter`'s
128
+ * placement (clients/bus-publish.ts). No-ops silently when `pi.events` or
129
+ * `.on` is unavailable (older pi host) — never throws.
130
+ */
131
+ export function wireAgentNudgeSubscriber(args) {
132
+ const { events, getReadGuard, dbg } = args;
133
+ if (!events?.on)
134
+ return;
135
+ try {
136
+ events.on(BUS_FILES_TOUCHED_EVENT, (data) => {
137
+ if (!isAgentNudgeEnabled())
138
+ return;
139
+ if (!isValidPayload(data))
140
+ return;
141
+ try {
142
+ recordTouchedEvent(data, getReadGuard);
143
+ }
144
+ catch (err) {
145
+ dbg?.(`agent-nudge: failed to record touched event: ${err}`);
146
+ }
147
+ });
148
+ }
149
+ catch (err) {
150
+ dbg?.(`agent-nudge: subscribe failed (older pi host?): ${err}`);
151
+ }
152
+ }
153
+ /**
154
+ * Consume the accumulated touched-file set and produce (at most) one context
155
+ * message, e.g.:
156
+ * "pi-lens: 2 file(s) were autoformatted after your last turn: a.ts, b.ts —
157
+ * working-tree changes to these are expected; re-read before editing."
158
+ * The provenance framing ("pi-lens ... expected") matters: the primary pain
159
+ * case is an agent running `git status` (often at the START of a brand-new
160
+ * run/session, not just mid-run) and burning turns investigating diffs it
161
+ * did not knowingly make. Naming pi-lens as the source lets the agent act
162
+ * (re-read, proceed) instead of investigating.
163
+ *
164
+ * #492: attribution is three-way by the batch's origin mix (see
165
+ * AccumulatedFileOrigin) — still ONE message total (never split local vs.
166
+ * cross-process into two separate injections; the agent gets one coherent
167
+ * picture of everything unexplained in the working tree), but the wording
168
+ * must never assign a LOCAL file to another instance:
169
+ * - all local → "after your last turn" (the original #485 wording,
170
+ * unchanged — verified by the pre-existing #485
171
+ * tests);
172
+ * - all cross-process → "by another pi-lens instance (e.g. a subagent's)";
173
+ * - mixed → "after your last turn (N of them by another
174
+ * pi-lens instance)" — the base framing stays local
175
+ * and the cross-process portion is counted out
176
+ * precisely, so no local file is ever misattributed
177
+ * to another instance.
178
+ *
179
+ * Clears the accumulator ONLY here, on actual injection — never on
180
+ * agent_end/agent_settled/turn_start. Files formatted at the last turn_end of
181
+ * a PREVIOUS run must still nudge at the first turn of the NEXT run in the
182
+ * same session: this function is invoked from the `context` extension event
183
+ * (index.ts), which fires before every provider/LLM call — including the
184
+ * very first call of a fresh `agent_start` — so the accumulator surviving
185
+ * across run boundaries is exactly what makes that cross-run delivery work.
186
+ * Empty accumulator ⇒ returns undefined ⇒ zero bytes injected.
187
+ */
188
+ export function consumeAgentNudge(dbg) {
189
+ const entries = Array.from(_touched.values());
190
+ _touched.clear();
191
+ const filesFiltered = _relevanceFilteredCount;
192
+ _relevanceFilteredCount = 0;
193
+ if (!isAgentNudgeEnabled())
194
+ return undefined;
195
+ if (entries.length === 0)
196
+ return undefined;
197
+ try {
198
+ const filesTotal = entries.length;
199
+ const shown = entries.slice(0, MAX_NAMES_SHOWN);
200
+ const remaining = filesTotal - shown.length;
201
+ // Determine a single verb covering every reason seen across all
202
+ // accumulated files (not just the shown subset) — most turns will have
203
+ // a single uniform reason, so keep that common case terse; a mix of
204
+ // autofix + format across the batch falls back to a combined verb.
205
+ const allReasons = new Set();
206
+ for (const e of entries) {
207
+ for (const r of e.reasons)
208
+ allReasons.add(r);
209
+ }
210
+ const verbLabel = allReasons.size > 1
211
+ ? "autofixed/reformatted"
212
+ : allReasons.has("format")
213
+ ? "reformatted"
214
+ : "autofixed";
215
+ const names = shown.map((e) => e.displayPath);
216
+ const nameList = remaining > 0
217
+ ? `${names.join(", ")}, and ${remaining} more`
218
+ : names.join(", ");
219
+ const crossProcessCount = entries.filter((e) => e.origin === "cross-process").length;
220
+ const localCount = filesTotal - crossProcessCount;
221
+ // Three-way attribution (see the function doc): never assign a local
222
+ // file to another instance — a mixed batch keeps the local base framing
223
+ // and calls out the cross-process portion by exact count.
224
+ const attribution = localCount === 0
225
+ ? "by another pi-lens instance (e.g. a subagent's)"
226
+ : crossProcessCount === 0
227
+ ? "after your last turn"
228
+ : `after your last turn (${crossProcessCount} of them by another pi-lens instance)`;
229
+ const message = `pi-lens: ${filesTotal} file(s) were ${verbLabel} ${attribution}: ${nameList} — working-tree changes to these are expected; re-read before editing.`;
230
+ logLatency({
231
+ type: "phase",
232
+ filePath: "<pi-lens>",
233
+ phase: "agent_nudge",
234
+ durationMs: 0,
235
+ metadata: {
236
+ filesTotal,
237
+ filesShown: shown.length,
238
+ // Relevance-filter drops since the last consume (files the session
239
+ // never read/edited) — NOT the display overflow, which is
240
+ // filesTotal - filesShown.
241
+ filesFiltered,
242
+ reasonMix: Array.from(allReasons),
243
+ // #492: origin mix so cross-process pickup rate is observable
244
+ // alongside the existing relevance-filter metric.
245
+ originLocal: localCount,
246
+ originCrossProcess: crossProcessCount,
247
+ },
248
+ });
249
+ return {
250
+ messages: [
251
+ {
252
+ role: "user",
253
+ content: `[pi-lens automated context — not a user request] ${message}`,
254
+ },
255
+ ],
256
+ };
257
+ }
258
+ catch (err) {
259
+ dbg?.(`agent-nudge: consume failed: ${err}`);
260
+ return undefined;
261
+ }
262
+ }
@@ -8,9 +8,9 @@
8
8
  * Docs: https://biomejs.dev/
9
9
  */
10
10
  import * as fs from "node:fs";
11
- import * as os from "node:os";
12
11
  import * as path from "node:path";
13
12
  import { isFileKind } from "./file-kinds.js";
13
+ import { getGlobalPiLensDir } from "./file-utils.js";
14
14
  import { findGlobalBinary } from "./package-manager.js";
15
15
  import { safeSpawnAsync } from "./safe-spawn.js";
16
16
  // --- Client ---
@@ -56,7 +56,7 @@ export class BiomeClient {
56
56
  // the tool is already installed but not in the project's node_modules.
57
57
  // On Windows prefer .cmd (native batch) over the sh wrapper — 2x faster.
58
58
  const isWin = process.platform === "win32";
59
- const piLensBin = path.join(os.homedir(), ".pi-lens", "tools", "node_modules", ".bin");
59
+ const piLensBin = path.join(getGlobalPiLensDir(), "tools", "node_modules", ".bin");
60
60
  const candidates = isWin
61
61
  ? [
62
62
  path.join(resolveCwd, "node_modules", ".bin", "biome.cmd"),
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Publishes `pilens:files:touched` on pi's shared `pi.events` bus (#482).
3
+ *
4
+ * This is pi-lens's FIRST `pi.events` broadcast surface: it exists so other
5
+ * extensions in the same session can observe files pi-lens writes
6
+ * autonomously (autofix/format runners) without reverse-engineering us —
7
+ * writes the agent makes itself via its own tool calls are NOT ours to
8
+ * broadcast; the host already knows about those (see the seam audit in
9
+ * AGENTS.md / issue #482).
10
+ *
11
+ * Versioning policy: the payload is frozen-additive. New optional fields may
12
+ * be added under the same `v: 1`; a breaking/incompatible change to an
13
+ * existing field's meaning must bump `v`.
14
+ *
15
+ * Fire-and-forget: publishing must never affect the write path's success or
16
+ * latency. Any failure (bus unavailable, emit throws) is swallowed; a `dbg`
17
+ * callback is invoked at most once on first failure so a wired caller can log
18
+ * it without spamming.
19
+ */
20
+ import { normalizeFilePath } from "./path-utils.js";
21
+ import { appendRecentTouches } from "./recent-touches.js";
22
+ export const BUS_FILES_TOUCHED_EVENT = "pilens:files:touched";
23
+ export const BUS_FILES_TOUCHED_VERSION = 1;
24
+ let busEmit;
25
+ let hasLoggedFailure = false;
26
+ /**
27
+ * Wire the emit function from pi's `pi.events` bus. Called once at extension
28
+ * factory time from index.ts (module-level singleton, same pattern as
29
+ * `initLensEvents`). Never called ⇒ `publishFilesTouched` no-ops, which is
30
+ * exactly the state unit tests and the MCP server path run in (no pi host,
31
+ * no `pi.events`).
32
+ */
33
+ export function wireBusEmitter(emitFn) {
34
+ busEmit = emitFn;
35
+ }
36
+ /** Test-only: reset module state between test files. */
37
+ export function _resetForTests() {
38
+ busEmit = undefined;
39
+ hasLoggedFailure = false;
40
+ _envCache = undefined;
41
+ }
42
+ let _envCache;
43
+ /**
44
+ * Lazy env read (house style) so tests can flip `PI_LENS_BUS_PUBLISH` at
45
+ * runtime via `_resetForTests` + re-set the env var. Kill switch: set to `0`
46
+ * to disable publishing outright.
47
+ */
48
+ export function isBusPublishEnabled() {
49
+ if (_envCache === undefined) {
50
+ _envCache = process.env.PI_LENS_BUS_PUBLISH !== "0";
51
+ }
52
+ return _envCache;
53
+ }
54
+ /**
55
+ * Publish one `pilens:files:touched` event for a logical write batch (one
56
+ * event per call site invocation, not per file). Fire-and-forget: never
57
+ * throws, never awaited by the caller's write path.
58
+ *
59
+ * #492: this is also the producer seam for the cross-process
60
+ * `recent-touches.json` record (clients/recent-touches.ts) — parent and
61
+ * child pi processes run this exact function, so appending here (rather
62
+ * than at each of the several `publishFilesTouched` call sites) guarantees
63
+ * every future call site gets cross-process propagation for free. The
64
+ * append is independent of `busEmit` being wired (a bare/MCP/test host with
65
+ * no `pi.events` still gets a cross-process record — the in-process bus and
66
+ * the on-disk record are two separate deliveries of the same payload, and
67
+ * the record is the ONLY one of the two that survives a process boundary).
68
+ * Fire-and-forget, same as the bus emit: never awaited, failures swallowed
69
+ * and dbg-logged (never break the publish path).
70
+ */
71
+ export function publishFilesTouched(args) {
72
+ if (args.origin === "bus")
73
+ return;
74
+ if (args.paths.length === 0)
75
+ return;
76
+ if (!isBusPublishEnabled())
77
+ return;
78
+ void appendRecentTouches({
79
+ cwd: args.cwd,
80
+ reason: args.reason,
81
+ paths: args.paths,
82
+ sessionId: args.sessionId,
83
+ }).catch((err) => {
84
+ args.dbg?.(`bus-publish: recent-touches append failed: ${err}`);
85
+ });
86
+ if (!busEmit)
87
+ return;
88
+ try {
89
+ const payload = {
90
+ v: BUS_FILES_TOUCHED_VERSION,
91
+ source: "pi-lens",
92
+ reason: args.reason,
93
+ paths: args.paths.map((p) => normalizeFilePath(p)),
94
+ cwd: normalizeFilePath(args.cwd),
95
+ };
96
+ if (args.fixes && args.fixes.length > 0) {
97
+ payload.fixes = args.fixes.map((f) => ({
98
+ ...f,
99
+ path: normalizeFilePath(f.path),
100
+ }));
101
+ }
102
+ busEmit(BUS_FILES_TOUCHED_EVENT, payload);
103
+ }
104
+ catch (err) {
105
+ if (!hasLoggedFailure) {
106
+ hasLoggedFailure = true;
107
+ args.dbg?.(`bus-publish: pilens:files:touched emit failed (further failures suppressed): ${err}`);
108
+ }
109
+ }
110
+ }
@@ -1,3 +1,22 @@
1
+ /**
2
+ * Centralized LAZY accessor for `@ast-grep/napi` (a native addon — loaded on
3
+ * demand, never at module-eval). See ./typescript.ts for the rationale.
4
+ * Types are re-exported; the module itself is fetched via `loadAstGrepNapi()`.
5
+ *
6
+ * Resolved to an absolute `file://` URL via `createRequire` before importing: an
7
+ * absolute-path dynamic import works under pi's bundled host, a bare specifier
8
+ * does not. The path is converted to a `file://` URL (a raw Windows path is not
9
+ * a valid import specifier); bare import kept as a fallback.
10
+ */
11
+ import { createRequire } from "node:module";
12
+ import { pathToFileURL } from "node:url";
13
+ const _require = createRequire(import.meta.url);
1
14
  export function loadAstGrepNapi() {
2
- return import("@ast-grep/napi");
15
+ try {
16
+ const entry = _require.resolve("@ast-grep/napi");
17
+ return import(pathToFileURL(entry).href);
18
+ }
19
+ catch {
20
+ return import("@ast-grep/napi");
21
+ }
3
22
  }
@@ -1,5 +1,8 @@
1
1
  /**
2
2
  * Centralized accessor for `@earendil-works/pi-tui`. See ./typescript.ts for the
3
3
  * rationale. (pi-tui is a pi-bundled core package, host-provided at runtime.)
4
+ *
5
+ * Re-export named bindings, not `export *`: with the package kept external, a
6
+ * wildcard re-export leaves the namespace undefined at runtime under the bundle.
4
7
  */
5
- export * from "@earendil-works/pi-tui";
8
+ export { Text, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
@@ -1,6 +1,9 @@
1
1
  /**
2
2
  * Centralized accessor for `typebox`. See ./typescript.ts for the rationale.
3
3
  * (typebox is a pi-bundled core package, so it resolves from the host at
4
- * runtime but it's still routed through here for a uniform dep surface.)
4
+ * runtime -- but it's still routed through here for a uniform dep surface.)
5
+ *
6
+ * Re-export named bindings, not `export *`: with the package kept external, a
7
+ * wildcard re-export leaves the namespace undefined at runtime under the bundle.
5
8
  */
6
- export * from "typebox";
9
+ export { Type } from "typebox";
@@ -1,3 +1,24 @@
1
+ /**
2
+ * Centralized LAZY accessor for `web-tree-sitter` (wasm — loaded on demand). See
3
+ * ./typescript.ts for the rationale. Types are re-exported; the module itself is
4
+ * fetched via `loadWebTreeSitter()`.
5
+ *
6
+ * Resolved to an absolute `file://` URL via `createRequire` before importing: an
7
+ * absolute-path dynamic import works under pi's bundled host, a bare specifier
8
+ * does not. The package `exports` map exposes only the `.` entry (no custom
9
+ * subpath), so we resolve the bare package name and convert it to a `file://`
10
+ * URL (a raw Windows path is not a valid import specifier); bare import kept as
11
+ * a fallback.
12
+ */
13
+ import { createRequire } from "node:module";
14
+ import { pathToFileURL } from "node:url";
15
+ const _require = createRequire(import.meta.url);
1
16
  export function loadWebTreeSitter() {
2
- return import("web-tree-sitter");
17
+ try {
18
+ const entry = _require.resolve("web-tree-sitter");
19
+ return import(pathToFileURL(entry).href);
20
+ }
21
+ catch {
22
+ return import("web-tree-sitter");
23
+ }
3
24
  }
@@ -3,12 +3,12 @@
3
3
  *
4
4
  * Log file: ~/.pi-lens/logs/{date}.jsonl
5
5
  */
6
- import * as os from "node:os";
7
6
  import * as path from "node:path";
8
7
  import { isTestMode } from "./env-utils.js";
8
+ import { getGlobalPiLensDir } from "./file-utils.js";
9
9
  import { createNdjsonLogger } from "./ndjson-logger.js";
10
10
  function getLogDir() {
11
- return path.join(os.homedir(), ".pi-lens", "logs");
11
+ return path.join(getGlobalPiLensDir(), "logs");
12
12
  }
13
13
  function getLogFile() {
14
14
  const date = new Date().toISOString().split("T")[0];
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Publishes `pilens:diagnostics` on pi's shared `pi.events` bus (#502).
3
+ *
4
+ * Sibling to `clients/bus-publish.ts` (the #482 `pilens:files:touched`
5
+ * producer) rather than a new export crammed into that file: the two events
6
+ * share the emit plumbing (`wireBusEmitter`) and the `PI_LENS_BUS_PUBLISH`
7
+ * kill switch, but this producer owns its OWN piece of module state (the
8
+ * previously-reported-paths set for clean-transition tracking, the seq
9
+ * counter) that has nothing to do with files-touched.
10
+ *
11
+ * ## CONSUMER CONTRACT — staleness / replace semantics (2026-07-11 design, #502)
12
+ *
13
+ * Diagnostics are STATE; bus events are point-in-time snapshots of a slice of
14
+ * that state. To guarantee a consumer can always reconstruct the latest
15
+ * known picture from the event stream alone, this producer follows LSP
16
+ * `publishDiagnostics` semantics:
17
+ *
18
+ * 1. **Full-replace per file, never a delta.** Every event carries the
19
+ * COMPLETE current diagnostic set for each file it mentions. An event
20
+ * mentioning path P replaces everything a consumer previously held for P
21
+ * — never merge/append across events for the same path.
22
+ * 2. **Empty array = explicitly clean.** When a previously-reported file's
23
+ * diagnostics clear, this producer emits `{path, diagnostics: []}` for it
24
+ * exactly once, on the transition. Silence never means clean (the #240
25
+ * doctrine, applied here on the producer side) — a consumer that stops
26
+ * hearing about a path has learned NOTHING about its current state.
27
+ * 3. **Monotonic `seq` + `ts` per emission.** `seq` increments once per
28
+ * `publishDiagnostics` call (module-level counter, process-lifetime
29
+ * monotonic — never reset except in tests). Out-of-order receipt resolves
30
+ * deterministically: higher `seq` always wins, lower is discarded.
31
+ * 4. **`pilens:files:touched` (#482) is an INVALIDATION HINT, not new data.**
32
+ * Between an edit landing (a files:touched event) and the next
33
+ * diagnostics batch for that path, a consumer's previously-held
34
+ * diagnostics for that path are PROVISIONAL — the file has changed on
35
+ * disk but pi-lens hasn't re-analyzed it yet. Consumers that want to
36
+ * avoid rendering stale annotations across that window should treat a
37
+ * files:touched path as "diagnostics pending" until the next
38
+ * pilens:diagnostics event mentions it (at any seq).
39
+ *
40
+ * Late-joiners are a non-problem in-process: extensions activate at
41
+ * `session_start`, before any turn emits, so v1 is push-only (no
42
+ * request/replay). #478's future `pilens:rpc:diagnostics` pull API reuses
43
+ * this exact `PilensDiagnosticsPayload` shape verbatim — push and pull are
44
+ * two deliveries of the same schema over the same lens-engine seam; #478
45
+ * stays separately gated on #449 registry dogfooding.
46
+ *
47
+ * ## Emission seam
48
+ *
49
+ * `publishDiagnostics` is called once per write batch immediately after
50
+ * `recordDiagnostics` (clients/widget-state.ts) commits the FINAL per-file
51
+ * diagnostic set for that batch — i.e. after format, autofix, and dispatch
52
+ * have all run (see pipeline.ts's phase order). This guarantees the emitted
53
+ * event reflects post-batch latest state, not an intermediate runner result:
54
+ * widget-state's `allDiagnostics` store is exactly what `recordDiagnostics`
55
+ * just wrote, so reading it back at the same call site can't race a later
56
+ * write in the same batch.
57
+ *
58
+ * Versioning policy: frozen-additive, same discipline as #482. New optional
59
+ * fields may be added under `v: 1`; a breaking change to an existing field's
60
+ * meaning must bump `v`.
61
+ */
62
+ import { normalizeFilePath } from "./path-utils.js";
63
+ import { isBusPublishEnabled } from "./bus-publish.js";
64
+ export const BUS_DIAGNOSTICS_EVENT = "pilens:diagnostics";
65
+ export const BUS_DIAGNOSTICS_VERSION = 1;
66
+ /** Max diagnostics carried per file per event — aligned with the widget's own per-file storage cap (`MAX_STORED_DIAGNOSTICS_PER_FILE`, clients/widget-state.ts). */
67
+ export const MAX_DIAGNOSTICS_PER_FILE_EVENT = 12;
68
+ let busEmit;
69
+ let hasLoggedFailure = false;
70
+ let seqCounter = 0;
71
+ /** Paths this producer has reported with at least one non-empty diagnostics array, so we know when to fire the one-time clean-transition event. */
72
+ const reportedDirtyPaths = new Set();
73
+ /**
74
+ * Wire the emit function from pi's `pi.events` bus. Called once at extension
75
+ * factory time from index.ts, same call as `wireBusEmitter` (#482) — both
76
+ * producers share the identical `pi.events.emit` binding.
77
+ */
78
+ export function wireDiagnosticsBusEmitter(emitFn) {
79
+ busEmit = emitFn;
80
+ }
81
+ /** Test-only: reset module state between test files. */
82
+ export function _resetDiagnosticsPublishForTests() {
83
+ busEmit = undefined;
84
+ hasLoggedFailure = false;
85
+ seqCounter = 0;
86
+ reportedDirtyPaths.clear();
87
+ }
88
+ function capDiagnostics(diagnostics) {
89
+ if (diagnostics.length <= MAX_DIAGNOSTICS_PER_FILE_EVENT) {
90
+ return { capped: diagnostics, truncated: false };
91
+ }
92
+ // Prioritize errors first (same "blockers first" spirit as the widget cap).
93
+ const errors = diagnostics.filter((d) => d.severity === "error");
94
+ const rest = diagnostics.filter((d) => d.severity !== "error");
95
+ if (errors.length >= MAX_DIAGNOSTICS_PER_FILE_EVENT) {
96
+ return {
97
+ capped: errors.slice(0, MAX_DIAGNOSTICS_PER_FILE_EVENT),
98
+ truncated: true,
99
+ };
100
+ }
101
+ return {
102
+ capped: [
103
+ ...errors,
104
+ ...rest.slice(0, MAX_DIAGNOSTICS_PER_FILE_EVENT - errors.length),
105
+ ],
106
+ truncated: true,
107
+ };
108
+ }
109
+ /**
110
+ * Publish one `pilens:diagnostics` event for a write batch's final
111
+ * per-file diagnostic state. Fire-and-forget: never throws, never awaited.
112
+ *
113
+ * Full-replace semantics (see module doc): each `files` entry here is
114
+ * treated as the COMPLETE current set for that path. Additionally, for
115
+ * every path in `reportedDirtyPaths` that is NOT present in this call's
116
+ * `files` list but has gone clean via a prior call in THIS same invocation,
117
+ * callers must pass an explicit `{path, diagnostics: []}` entry — this
118
+ * function does not infer clean transitions for paths it isn't told about.
119
+ * The one caller (the pipeline write-batch seam) always passes the
120
+ * single file it just analyzed, so the common clean-transition path is:
121
+ * dispatch returns zero diagnostics for a file this producer previously
122
+ * reported dirty -> caller passes `{path, diagnostics: []}` -> emitted once,
123
+ * `reportedDirtyPaths` drops the path so a SECOND clean run of the same file
124
+ * does not re-emit (still clean -> silence is fine once the transition
125
+ * itself has been announced).
126
+ */
127
+ export function publishDiagnostics(args) {
128
+ if (args.origin === "bus")
129
+ return;
130
+ if (args.files.length === 0)
131
+ return;
132
+ if (!isBusPublishEnabled())
133
+ return;
134
+ if (!busEmit)
135
+ return;
136
+ try {
137
+ const fileEntries = args.files.map((f) => {
138
+ const normPath = normalizeFilePath(f.path);
139
+ const { capped, truncated } = capDiagnostics(f.diagnostics);
140
+ if (capped.length > 0) {
141
+ reportedDirtyPaths.add(normPath);
142
+ }
143
+ else {
144
+ reportedDirtyPaths.delete(normPath);
145
+ }
146
+ const entry = {
147
+ path: normPath,
148
+ diagnostics: capped,
149
+ };
150
+ if (truncated)
151
+ entry.truncated = true;
152
+ return entry;
153
+ });
154
+ seqCounter += 1;
155
+ const payload = {
156
+ v: BUS_DIAGNOSTICS_VERSION,
157
+ source: "pi-lens",
158
+ cwd: normalizeFilePath(args.cwd),
159
+ seq: seqCounter,
160
+ ts: Date.now(),
161
+ files: fileEntries,
162
+ };
163
+ busEmit(BUS_DIAGNOSTICS_EVENT, payload);
164
+ }
165
+ catch (err) {
166
+ if (!hasLoggedFailure) {
167
+ hasLoggedFailure = true;
168
+ args.dbg?.(`diagnostics-publish: pilens:diagnostics emit failed (further failures suppressed): ${err}`);
169
+ }
170
+ }
171
+ }
172
+ /**
173
+ * Whether `path` was last reported with a non-empty diagnostic set (i.e. a
174
+ * clean run for it now would be a transition worth emitting `[]` for).
175
+ * Exposed for the pipeline call site to decide whether to include a
176
+ * currently-clean file in the batch it passes to `publishDiagnostics`.
177
+ */
178
+ export function wasPreviouslyReportedDirty(path) {
179
+ return reportedDirtyPaths.has(normalizeFilePath(path));
180
+ }