@herbertgao/pi-extensions 2026.8.7 → 2026.8.8

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 (99) hide show
  1. package/README.md +7 -5
  2. package/node_modules/@herbertgao/pi-cc-extensions/README.en.md +1 -1
  3. package/node_modules/@herbertgao/pi-cc-extensions/README.md +1 -1
  4. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/compact-thinking.ts +63 -15
  5. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/message-display.ts +78 -16
  6. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/hover.ts +13 -0
  7. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/interaction.ts +17 -6
  8. package/node_modules/@herbertgao/pi-cc-extensions/package.json +3 -3
  9. package/node_modules/@juicesharp/rpiv-ask-user-question/README.md +4 -4
  10. package/node_modules/@juicesharp/rpiv-ask-user-question/ask-user-question.ts +154 -96
  11. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/hosts.md +3 -0
  12. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/keyboard.md +10 -2
  13. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/localization.md +7 -5
  14. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/tool-schema.md +11 -5
  15. package/node_modules/@juicesharp/rpiv-ask-user-question/locales/en.json +3 -0
  16. package/node_modules/@juicesharp/rpiv-ask-user-question/package.json +2 -2
  17. package/node_modules/@juicesharp/rpiv-ask-user-question/state/key-router.ts +137 -96
  18. package/node_modules/@juicesharp/rpiv-ask-user-question/state/questionnaire-session.ts +44 -32
  19. package/node_modules/@juicesharp/rpiv-ask-user-question/state/selectors/projections.ts +22 -15
  20. package/node_modules/@juicesharp/rpiv-ask-user-question/state/state-reducer.ts +11 -1
  21. package/node_modules/@juicesharp/rpiv-ask-user-question/tool/response-envelope.ts +11 -0
  22. package/node_modules/@juicesharp/rpiv-ask-user-question/tool/types.ts +11 -0
  23. package/node_modules/@juicesharp/rpiv-ask-user-question/view/components/multi-select-view.ts +31 -17
  24. package/node_modules/@juicesharp/rpiv-ask-user-question/view/components/preview/preview-block-renderer.ts +15 -6
  25. package/node_modules/@juicesharp/rpiv-ask-user-question/view/components/wrapping-select.ts +15 -6
  26. package/node_modules/@juicesharp/rpiv-ask-user-question/view/dialog-builder.ts +56 -34
  27. package/node_modules/@juicesharp/rpiv-ask-user-question/view/tab-content-strategy.ts +50 -4
  28. package/node_modules/@narumitw/pi-btw/package.json +2 -2
  29. package/node_modules/@pi-plugins/fast-mode/dist/index.mjs +44 -1856
  30. package/node_modules/@pi-plugins/fast-mode/dist/index.mjs.map +1 -1
  31. package/node_modules/@pi-plugins/fast-mode/package.json +4 -6
  32. package/node_modules/pi-lens/CHANGELOG.md +90 -0
  33. package/node_modules/pi-lens/README.md +106 -0
  34. package/node_modules/pi-lens/dist/clients/bash-file-access.js +97 -6
  35. package/node_modules/pi-lens/dist/clients/blocker-past-eof.js +16 -6
  36. package/node_modules/pi-lens/dist/clients/bounded-telemetry.js +14 -0
  37. package/node_modules/pi-lens/dist/clients/cache-observability.js +353 -76
  38. package/node_modules/pi-lens/dist/clients/degradation-ledger.js +22 -1
  39. package/node_modules/pi-lens/dist/clients/demoted-finding-render.js +155 -0
  40. package/node_modules/pi-lens/dist/clients/deps/pi-tui.js +8 -2
  41. package/node_modules/pi-lens/dist/clients/deps/typebox.js +8 -3
  42. package/node_modules/pi-lens/dist/clients/dispatch/runners/detekt.js +9 -6
  43. package/node_modules/pi-lens/dist/clients/dispatch/runners/hadolint.js +9 -7
  44. package/node_modules/pi-lens/dist/clients/dispatch/runners/htmlhint.js +8 -6
  45. package/node_modules/pi-lens/dist/clients/dispatch/runners/markdownlint.js +8 -10
  46. package/node_modules/pi-lens/dist/clients/dispatch/runners/mypy.js +10 -10
  47. package/node_modules/pi-lens/dist/clients/dispatch/runners/oxlint.js +20 -5
  48. package/node_modules/pi-lens/dist/clients/dispatch/runners/phpstan.js +44 -11
  49. package/node_modules/pi-lens/dist/clients/dispatch/runners/spellcheck.js +10 -15
  50. package/node_modules/pi-lens/dist/clients/dispatch/runners/sqlfluff.js +19 -11
  51. package/node_modules/pi-lens/dist/clients/dispatch/runners/stylelint.js +35 -13
  52. package/node_modules/pi-lens/dist/clients/dispatch/runners/swiftlint.js +8 -8
  53. package/node_modules/pi-lens/dist/clients/dispatch/runners/taplo.js +110 -23
  54. package/node_modules/pi-lens/dist/clients/dispatch/runners/tflint.js +12 -7
  55. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/tool-failure.js +117 -1
  56. package/node_modules/pi-lens/dist/clients/dispatch/runners/vale.js +16 -14
  57. package/node_modules/pi-lens/dist/clients/dispatch/runners/yamllint.js +8 -9
  58. package/node_modules/pi-lens/dist/clients/finding-delivery-gate.js +22 -0
  59. package/node_modules/pi-lens/dist/clients/jscpd-client.js +5 -2
  60. package/node_modules/pi-lens/dist/clients/language-profile.js +7 -2
  61. package/node_modules/pi-lens/dist/clients/lsp/cascade-tier.js +27 -2
  62. package/node_modules/pi-lens/dist/clients/lsp/client.js +144 -5
  63. package/node_modules/pi-lens/dist/clients/lsp/index.js +151 -6
  64. package/node_modules/pi-lens/dist/clients/module-report.js +14 -1
  65. package/node_modules/pi-lens/dist/clients/ndjson-logger.js +114 -23
  66. package/node_modules/pi-lens/dist/clients/project-diagnostics/scanner.js +18 -9
  67. package/node_modules/pi-lens/dist/clients/project-report.js +62 -27
  68. package/node_modules/pi-lens/dist/clients/read-guard-logger.js +7 -2
  69. package/node_modules/pi-lens/dist/clients/read-guard.js +88 -19
  70. package/node_modules/pi-lens/dist/clients/review-graph/builder.js +194 -31
  71. package/node_modules/pi-lens/dist/clients/review-graph/revision-drift.js +21 -0
  72. package/node_modules/pi-lens/dist/clients/review-graph/workspace-modules.js +6 -3
  73. package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +44 -0
  74. package/node_modules/pi-lens/dist/clients/runtime-session.js +41 -0
  75. package/node_modules/pi-lens/dist/clients/runtime-turn.js +47 -2
  76. package/node_modules/pi-lens/dist/clients/session-event-guard.js +184 -0
  77. package/node_modules/pi-lens/dist/clients/source-filter.js +6 -2
  78. package/node_modules/pi-lens/dist/clients/startup-scan.js +5 -2
  79. package/node_modules/pi-lens/dist/clients/tree-sitter-logger.js +1 -0
  80. package/node_modules/pi-lens/dist/index.js +1224 -393
  81. package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +10 -3
  82. package/node_modules/pi-lens/package.json +18 -5
  83. package/node_modules/pi-lens/scripts/install-selftest.mjs +99 -11
  84. package/node_modules/pi-lens/scripts/lib/host-provided-deps.mjs +75 -0
  85. package/node_modules/pi-lens/scripts/lib/warm-loader-cache.mjs +285 -0
  86. package/node_modules/pi-lens/scripts/warm-loader-cache.mjs +233 -0
  87. package/node_modules/pi-web-access/CHANGELOG.md +14 -0
  88. package/node_modules/pi-web-access/README.md +6 -5
  89. package/node_modules/pi-web-access/chrome-cookies.ts +86 -15
  90. package/node_modules/pi-web-access/data-uri-sanitize.ts +406 -0
  91. package/node_modules/pi-web-access/extract.ts +12 -2
  92. package/node_modules/pi-web-access/firecrawl.ts +18 -2
  93. package/node_modules/pi-web-access/github-extract.ts +65 -36
  94. package/node_modules/pi-web-access/index.ts +19 -4
  95. package/node_modules/pi-web-access/openai-search.ts +17 -6
  96. package/node_modules/pi-web-access/package.json +1 -1
  97. package/node_modules/pi-web-access/pdf-extract.ts +18 -5
  98. package/node_modules/pi-web-access/ssrf-protection.ts +12 -2
  99. package/package.json +9 -9
@@ -349,9 +349,16 @@ captureLspStatusRepaint, getRuntime) {
349
349
  }
350
350
  // ── delta mode ────────────────────────────────────────────────────────────────
351
351
  function formatProjectDeltaDiagnostic(diagnostic, stale) {
352
- const marker = diagnostic.semantic === "blocking" || diagnostic.severity === "error"
353
- ? "🔴"
354
- : "";
352
+ // #1944: a demoted row loses the authority marker along with its
353
+ // coordinate. It kept the 🔴 while its line was replaced by the stale
354
+ // marker, which is the same "changed the channel, not the body" defect the
355
+ // turn-end advisory carried — `formatFullMode` (this file, the `d.stale`
356
+ // arm of its marker) already drops the marker for exactly this reason.
357
+ const marker = stale
358
+ ? "○"
359
+ : diagnostic.semantic === "blocking" || diagnostic.severity === "error"
360
+ ? "🔴"
361
+ : "ℹ";
355
362
  const rule = diagnostic.rule ?? diagnostic.code ?? diagnostic.runner;
356
363
  const where = stale ? STALE_LINE_MARKER : `L${diagnostic.line ?? "?"}`;
357
364
  return ` ${marker} ${where} ${rule} ${diagnostic.message}`;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-lens",
3
- "version": "4.1.0",
3
+ "version": "4.1.1",
4
4
  "type": "module",
5
5
  "description": "Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo",
6
6
  "repository": {
@@ -17,7 +17,7 @@
17
17
  "build": "tsc --project tsconfig.build.json",
18
18
  "build:dist": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && npx --yes -p typescript@7.0.2 tsc --project tsconfig.dist.json --noCheck && npm run bundle:dist",
19
19
  "bundle:dist": "node scripts/bundle-dist.mjs",
20
- "prepare": "npm run build:dist && node scripts/download-grammars.js --core --dest grammars",
20
+ "prepare": "npm run build:dist && node scripts/download-grammars.js --core --dest grammars && node scripts/setup-git-hooks.mjs && node scripts/warm-loader-cache.mjs",
21
21
  "lint": "tsc --project tsconfig.json",
22
22
  "watch": "tsc --watch",
23
23
  "test": "node scripts/with-test-lock.mjs -- vitest run",
@@ -80,6 +80,9 @@
80
80
  "scripts/grammars.lock.json",
81
81
  "scripts/analyze-pi-lens-logs.mjs",
82
82
  "scripts/install-selftest.mjs",
83
+ "scripts/lib/host-provided-deps.mjs",
84
+ "scripts/lib/warm-loader-cache.mjs",
85
+ "scripts/warm-loader-cache.mjs",
83
86
  "scripts/rpc-load-check.mjs",
84
87
  "README.md",
85
88
  "CHANGELOG.md",
@@ -89,30 +92,40 @@
89
92
  "dependencies": {
90
93
  "@ast-grep/cli": "^0.45.0",
91
94
  "@ast-grep/napi": "^0.45.0",
92
- "@earendil-works/pi-tui": "^0.84.1",
93
95
  "js-yaml": "^5.2.2",
94
96
  "minimatch": "^10.2.6",
95
97
  "pidusage": "^4.0.1",
96
- "typebox": "^1.0.0",
97
98
  "vscode-jsonrpc": "^9.0.0"
98
99
  },
99
100
  "peerDependencies": {
100
- "@earendil-works/pi-coding-agent": "*"
101
+ "@earendil-works/pi-coding-agent": "*",
102
+ "@earendil-works/pi-tui": "^0.84.1",
103
+ "typebox": "^1.0.0"
101
104
  },
102
105
  "peerDependenciesMeta": {
103
106
  "@earendil-works/pi-coding-agent": {
104
107
  "optional": true
108
+ },
109
+ "@earendil-works/pi-tui": {
110
+ "optional": true
111
+ },
112
+ "typebox": {
113
+ "optional": true
105
114
  }
106
115
  },
107
116
  "optionalDependencies": {
117
+ "jiti": "^2.7.0",
108
118
  "web-tree-sitter": "0.25.10"
109
119
  },
110
120
  "devDependencies": {
111
121
  "@biomejs/biome": "^2.4.10",
112
122
  "@earendil-works/pi-coding-agent": "^0.84.1",
123
+ "@earendil-works/pi-tui": "^0.84.1",
113
124
  "@types/node": "^26.0.0",
114
125
  "@types/pidusage": "^2.0.5",
115
126
  "@vitest/coverage-v8": "^4.1.5",
127
+ "husky": "^9.1.7",
128
+ "typebox": "^1.0.0",
116
129
  "typescript": "^7.0.2",
117
130
  "typescript-language-server": "^5.1.3",
118
131
  "vitest": "^4.1.1"
@@ -19,6 +19,20 @@
19
19
  * build-script-provided assets (ast-grep CLI binary + tree-sitter grammars)
20
20
  * that pnpm/bun skip by default. It runs no model and needs no credentials.
21
21
  *
22
+ * WHAT IT NO LONGER COVERS (#1926)
23
+ * pi supplies `typebox` and `@earendil-works/pi-tui` from its own runtime, so
24
+ * they are optional peers and no install vendors them. This probe is not pi, so
25
+ * those specifiers cannot resolve here, and `dist/index.js` throws on the FIRST
26
+ * one it imports. A host-provided miss is therefore recorded as EXPECTED rather
27
+ * than failed. The cost is real and stated plainly: evaluation stops there, so
28
+ * the entry probe no longer proves the whole bundled graph resolves, which is
29
+ * what #285 and #335 asked it to prove. Any OTHER unresolved specifier still
30
+ * fails hard, and the per-module probes below (file-utils, complexity-client,
31
+ * bootstrap) still cover that graph directly. The surviving POSITIVE proof that
32
+ * the entry loads in full is the install-smoke `pi-load` job: it installs pi,
33
+ * installs pi-lens through pi, and confirms over RPC that pi-lens registered
34
+ * its commands.
35
+ *
22
36
  * USAGE
23
37
  * bun scripts/install-selftest.mjs # faithful: pi's runtime is bun
24
38
  * node scripts/install-selftest.mjs # also works
@@ -28,10 +42,11 @@
28
42
  */
29
43
 
30
44
  import { execFileSync } from "node:child_process";
45
+ import * as fs from "node:fs";
31
46
  import { createRequire } from "node:module";
32
- import { fileURLToPath } from "node:url";
33
47
  import * as path from "node:path";
34
- import * as fs from "node:fs";
48
+ import { fileURLToPath } from "node:url";
49
+ import { HOST_PROVIDED_PACKAGES } from "./lib/host-provided-deps.mjs";
35
50
 
36
51
  const here = path.dirname(fileURLToPath(import.meta.url));
37
52
  const pkgRoot = path.resolve(here, "..");
@@ -42,6 +57,26 @@ const results = [];
42
57
  const record = (name, kind, ok, detail = "") =>
43
58
  results.push({ name, kind, ok, detail });
44
59
 
60
+ /**
61
+ * Which host-provided package, if any, a resolution error is about. Returns
62
+ * undefined for every other error, so an unrelated missing package still fails.
63
+ */
64
+ function hostProvidedMiss(err) {
65
+ const text = `${err?.code || ""} ${err?.message || err}`;
66
+ if (
67
+ !/ERR_MODULE_NOT_FOUND|Cannot find (package|module)|ResolveMessage/.test(
68
+ text,
69
+ )
70
+ ) {
71
+ return undefined;
72
+ }
73
+ return HOST_PROVIDED_PACKAGES.find((name) =>
74
+ new RegExp(
75
+ `['"\`]${name.replace(/[/\\^$*+?.()|[\]{}]/g, "\\$&")}['"\`]`,
76
+ ).test(text),
77
+ );
78
+ }
79
+
45
80
  /** Import a module by URL and record whether its (eager) dep graph resolved. */
46
81
  async function probeImport(name, relPath) {
47
82
  const url = new URL(
@@ -51,7 +86,24 @@ async function probeImport(name, relPath) {
51
86
  await import(url.href);
52
87
  record(name, "resolve", true);
53
88
  } catch (err) {
54
- record(name, "resolve", false, `${err?.code || ""} ${err?.message || err}`.trim());
89
+ // #1926: pi supplies these; outside pi they cannot resolve, and the entry
90
+ // throws on the first one. Expected, not a regression. Anything else is.
91
+ const host = hostProvidedMiss(err);
92
+ if (host) {
93
+ record(
94
+ name,
95
+ "expected",
96
+ true,
97
+ `stopped at host-provided '${host}' — pi supplies it; graph beyond this point unproven here (#1926)`,
98
+ );
99
+ return;
100
+ }
101
+ record(
102
+ name,
103
+ "resolve",
104
+ false,
105
+ `${err?.code || ""} ${err?.message || err}`.trim(),
106
+ );
55
107
  }
56
108
  }
57
109
 
@@ -61,27 +113,53 @@ function probeResolve(spec) {
61
113
  require.resolve(spec);
62
114
  record(spec, "resolve", true);
63
115
  } catch (err) {
64
- record(spec, "resolve", false, `${err?.code || ""} ${err?.message || err}`.trim());
116
+ record(
117
+ spec,
118
+ "resolve",
119
+ false,
120
+ `${err?.code || ""} ${err?.message || err}`.trim(),
121
+ );
65
122
  }
66
123
  }
67
124
 
68
125
  // --- 1. The documented failure-point modules (eager bare imports) ----------
69
126
  await probeImport("dist/index.js (entry)", "dist/index.js");
70
- await probeImport("clients/file-utils.js (→ minimatch)", "dist/clients/file-utils.js");
71
- await probeImport("clients/complexity-client.js (→ tree-sitter)", "dist/clients/complexity-client.js");
72
- await probeImport("clients/bootstrap.js (→ all analyzers)", "dist/clients/bootstrap.js");
127
+ await probeImport(
128
+ "clients/file-utils.js (→ minimatch)",
129
+ "dist/clients/file-utils.js",
130
+ );
131
+ await probeImport(
132
+ "clients/complexity-client.js (→ tree-sitter)",
133
+ "dist/clients/complexity-client.js",
134
+ );
135
+ await probeImport(
136
+ "clients/bootstrap.js (→ all analyzers)",
137
+ "dist/clients/bootstrap.js",
138
+ );
73
139
 
74
140
  // --- 2. Direct bare-specifier resolution -----------------------------------
75
- for (const spec of [
141
+ // Host-provided packages are subtracted, not listed as exceptions: pi resolves
142
+ // them from its own runtime, so nothing installs them and probing one here is
143
+ // guaranteed to fail (#1926). The subtraction reads the same list the bundler
144
+ // and the packaging tests use, so it cannot drift.
145
+ const BARE_SPECIFIERS = [
76
146
  "minimatch",
77
147
  "typebox",
78
148
  "js-yaml",
79
149
  "vscode-jsonrpc",
80
150
  "web-tree-sitter",
81
151
  "@ast-grep/napi",
82
- ]) {
152
+ ].filter((spec) => !HOST_PROVIDED_PACKAGES.includes(spec));
153
+
154
+ for (const spec of BARE_SPECIFIERS) {
83
155
  probeResolve(spec);
84
156
  }
157
+ record(
158
+ "host-provided specifiers skipped",
159
+ "expected",
160
+ true,
161
+ `${HOST_PROVIDED_PACKAGES.join(", ")} — pi supplies these (#1926)`,
162
+ );
85
163
 
86
164
  // --- 3. Spawned-binary tools (the universal net) ----------------------------
87
165
  // For every tool that ships its binary in a per-platform npm package
@@ -121,11 +199,21 @@ try {
121
199
  });
122
200
  record(label, "tool", true, bin);
123
201
  } catch (err) {
124
- record(label, "tool", false, `${bin} failed to run: ${err?.message || err}`);
202
+ record(
203
+ label,
204
+ "tool",
205
+ false,
206
+ `${bin} failed to run: ${err?.message || err}`,
207
+ );
125
208
  }
126
209
  }
127
210
  } catch (err) {
128
- record("spawned-tool probe", "tool", false, `installer load failed: ${err?.message || err}`);
211
+ record(
212
+ "spawned-tool probe",
213
+ "tool",
214
+ false,
215
+ `installer load failed: ${err?.message || err}`,
216
+ );
129
217
  }
130
218
 
131
219
  // tree-sitter grammars — download-grammars.js postinstall writes them into
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Single source of truth for the packages `scripts/bundle-dist.mjs` keeps out of
3
+ * the dist bundle, split by the REASON they stay external. #1926.
4
+ *
5
+ * HOST_PROVIDED_PACKAGES
6
+ * pi ships these inside its own runtime (`@earendil-works/pi-coding-agent`'s
7
+ * node_modules). They must be declared as OPTIONAL peer dependencies plus dev
8
+ * dependencies, never as runtime `dependencies`, because a runtime dependency
9
+ * makes `npm install --omit=dev` — the command pi runs for a `git:` install —
10
+ * vendor a private second copy into the extension's own node_modules. Node
11
+ * then resolves the bare specifier to that copy and evaluates a whole extra
12
+ * module graph at import. That is #1926: vendoring `typebox` and
13
+ * `@earendil-works/pi-tui` cost 720ms of the 838ms git-install import, and
14
+ * removing the copies took the PI_TIMING module import from 941ms to ~180ms.
15
+ *
16
+ * The three entries are NOT interchangeable at runtime. pi resolves the bare
17
+ * specifiers `typebox` and `@earendil-works/pi-tui` for an extension, so
18
+ * VALUE imports of those work with no copy on disk — verified by hiding both
19
+ * from the dogfood install and re-running under PI_TIMING (#1926), and by
20
+ * `@tintinweb/pi-subagents`, which declares pi-tui as a peer and ships no
21
+ * copy. `@earendil-works/pi-coding-agent` is different: it is not resolvable
22
+ * from an extension at all, so pi-lens imports it TYPE-ONLY and inlines the
23
+ * runtime helpers it needs. `tests/host-sdk-type-only.test.ts` enforces that
24
+ * separate rule (#1334 S6); do not read this list as permission to
25
+ * value-import the host SDK.
26
+ *
27
+ * LAZY_NATIVE_PACKAGES
28
+ * Native addon / wasm, dynamic-imported by absolute file:// URL at call time.
29
+ * They stay external because esbuild cannot inline a .node or .wasm, and they
30
+ * cost nothing at load because nothing imports them statically.
31
+ *
32
+ * `tests/packaging.test.ts` pins both invariants against package.json and
33
+ * against the built dist entry, so this list cannot drift from what ships.
34
+ */
35
+
36
+ /**
37
+ * Host-provided packages the extension VALUE-imports. pi resolves these bare
38
+ * specifiers at runtime, so the compiled entry cannot load without them.
39
+ * Outside pi — a bare `node dist/index.js` in a CI smoke check — nothing
40
+ * supplies them, so a job that wants a FULL load has to install them first, the
41
+ * way pi does. `scripts/supply-host-provided-deps.mjs` does that.
42
+ */
43
+ export const HOST_PROVIDED_RUNTIME_PACKAGES = Object.freeze([
44
+ "typebox",
45
+ "@earendil-works/pi-tui",
46
+ ]);
47
+
48
+ /**
49
+ * Host-provided packages the extension imports TYPE-ONLY. pi does not resolve
50
+ * these for an extension at all, which is why every runtime helper pi-lens
51
+ * needs from the host SDK is inlined instead; `tests/host-sdk-type-only.test.ts`
52
+ * enforces that (#1334 S6). Never install one of these to make a load succeed:
53
+ * the transitive tree has nested paths that exceed Windows MAX_PATH.
54
+ */
55
+ export const HOST_PROVIDED_TYPE_ONLY_PACKAGES = Object.freeze([
56
+ "@earendil-works/pi-coding-agent",
57
+ ]);
58
+
59
+ /** Everything pi supplies, so pi-lens must never declare it a dependency. */
60
+ export const HOST_PROVIDED_PACKAGES = Object.freeze([
61
+ ...HOST_PROVIDED_RUNTIME_PACKAGES,
62
+ ...HOST_PROVIDED_TYPE_ONLY_PACKAGES,
63
+ ]);
64
+
65
+ /** Native/wasm packages dynamic-imported by absolute path at call time. */
66
+ export const LAZY_NATIVE_PACKAGES = Object.freeze([
67
+ "@ast-grep/napi",
68
+ "web-tree-sitter",
69
+ ]);
70
+
71
+ /** Everything esbuild must NOT inline into dist/index.js. */
72
+ export const BUNDLE_EXTERNALS = Object.freeze([
73
+ ...HOST_PROVIDED_PACKAGES,
74
+ ...LAZY_NATIVE_PACKAGES,
75
+ ]);
@@ -0,0 +1,285 @@
1
+ /**
2
+ * Pure helpers for `scripts/warm-loader-cache.mjs` (#1926).
3
+ *
4
+ * pi loads an extension through jiti (`createJiti(..., { moduleCache: false,
5
+ * alias: … })` in `@earendil-works/pi-coding-agent`'s
6
+ * `dist/core/extensions/loader.js`). jiti transforms the entry with Babel and
7
+ * stores the result in a filesystem cache. pi-lens's `dist/index.js` is a ~4MB
8
+ * esbuild bundle, so that transform costs seconds: the first session after a
9
+ * `git:` install or update measured 4847ms of `module import`, against a 138ms
10
+ * steady state once the cache is warm (#1926 field report).
11
+ *
12
+ * The fix is to pay that transform at install time instead of in the first
13
+ * interactive session. `prepare` already builds `dist/`, so the last step of
14
+ * the chain runs the same transform through the same jiti, writing the same
15
+ * cache file pi will later read.
16
+ *
17
+ * WHY THE WARM PRODUCES A HIT FOR THE REAL LOADER
18
+ *
19
+ * jiti keys a cache entry on the transformed file, not on the jiti instance
20
+ * (see `getCache` in `jiti/dist/jiti.cjs`):
21
+ *
22
+ * <basename(dirname(file))>-<basename(file) up to the first dot>
23
+ * + "+map" if sourceMaps + ".i" if interopDefault
24
+ * + "." + md5(file).slice(0, 8)
25
+ * + ".mjs" for an async import, ".cjs" otherwise
26
+ *
27
+ * and it validates the stored body against a trailing
28
+ * ` /* v<TRANSFORM_VERSION>-<md5(source).slice(0,16)> *\/` marker. So the warm
29
+ * has to agree with pi on three things only: the absolute path of the entry,
30
+ * the cache directory, and the transform itself. Everything else — the alias
31
+ * map, `moduleCache`, which package the jiti instance was created from — is
32
+ * outside the key AND outside the transform. Measured, not assumed: running
33
+ * this warm over the dogfood install's `dist/index.js` produced a cache file
34
+ * byte-identical to the one pi had written, both with pi's alias map and with
35
+ * no alias map at all.
36
+ *
37
+ * The marker is also the safety net. If pi ships a jiti whose transform version
38
+ * or Babel output differs, pi recomputes the marker, does not match, and simply
39
+ * re-transforms. A mismatched warm costs the install a few seconds and buys
40
+ * nothing; it can never hand pi the wrong code.
41
+ */
42
+
43
+ import { createHash, getFips } from "node:crypto";
44
+
45
+ /**
46
+ * Mirror of jiti's `utils_hash`: md5 truncated to `length` hex characters, or
47
+ * sha256 where FIPS mode forbids md5. jiti makes the same substitution, so the
48
+ * two agree on every machine.
49
+ *
50
+ * @param {string} text
51
+ * @param {number} [length]
52
+ * @returns {string}
53
+ */
54
+ export function jitiHash(text, length = 8) {
55
+ let fips = false;
56
+ try {
57
+ fips = Boolean(getFips?.());
58
+ } catch {
59
+ fips = false;
60
+ }
61
+ return createHash(fips ? "sha256" : "md5")
62
+ .update(text)
63
+ .digest("hex")
64
+ .slice(0, length);
65
+ }
66
+
67
+ /**
68
+ * The cache file jiti writes for an async import of `entry`.
69
+ *
70
+ * Mirror of jiti's `getCache` naming:
71
+ * `<basename(dirname(file))>-<basename up to the first dot>.<hash(file)>.mjs`.
72
+ * jiti hashes the POSIX-normalised absolute path, so the separators are
73
+ * converted here. The drive-letter case is left exactly as the caller resolved
74
+ * it, because jiti does not touch it either. Verified against pi's own cache
75
+ * directory: the md5 of the dogfood install's forward-slashed entry path,
76
+ * truncated to 8 characters, is the `db18768f` in the
77
+ * `dist-index.db18768f.mjs` pi had already written.
78
+ *
79
+ * @param {string} entry absolute path to the extension entry
80
+ * @returns {string}
81
+ */
82
+ export function expectedCacheFileName(entry) {
83
+ const posix = entry.split("\\").join("/");
84
+ const segments = posix.split("/");
85
+ const base = segments.pop() ?? posix;
86
+ const parent = segments.pop() ?? "";
87
+ const dot = base.indexOf(".");
88
+ const stem = dot <= 0 ? base : base.slice(0, dot);
89
+ return `${parent}-${stem}.${jitiHash(posix)}.mjs`;
90
+ }
91
+
92
+ /**
93
+ * Did the warm actually leave a cache entry pi can use?
94
+ *
95
+ * Elapsed time is not evidence. jiti can return from an import having written
96
+ * nothing, and every such path exits 0:
97
+ *
98
+ * - the cache directory is not writable. jiti's `prepareCacheDir` catches
99
+ * that, sets `fsCache` to false, and transforms in memory from then on.
100
+ * - the entry imported natively and nothing needed transforming. For an
101
+ * async-imported ESM `.js` file jiti's `evalModule` attempts a native
102
+ * import UNCONDITIONALLY, and only falls back to the transform when that
103
+ * import rejects. So the cache exists at all because native import fails in
104
+ * a real `--omit=dev` install, where the host-provided specifiers do not
105
+ * resolve. That is the same reason pi's own load transforms the bundle,
106
+ * which is why the warm and pi agree — but it is a property of the
107
+ * environment, not of this script.
108
+ * - a cache entry exists but does not match the source. jiti validates the
109
+ * body against a trailing version-and-source-hash marker, so a stale or
110
+ * truncated file is a miss for pi and the session pays the transform.
111
+ *
112
+ * Checking the file is what turns "the import returned" into "the warm's own
113
+ * jiti cached this entry, with these versions". It is not a promise about pi:
114
+ * pi-side drift shows up as a version delta between this record and pi's, which
115
+ * is a thing to read from the log, not something an install can detect.
116
+ * An absent entry always warrants a look, never a shrug.
117
+ *
118
+ * @param {object} args
119
+ * @param {string} args.cacheDir
120
+ * @param {string} args.fileName
121
+ * @param {string} args.source contents of the entry, for the marker check
122
+ * @param {object} args.fsDeps
123
+ * @param {(p: string) => boolean} args.fsDeps.existsSync
124
+ * @param {(p: string) => string} args.fsDeps.readFileSync
125
+ * @param {(p: string) => boolean} args.fsDeps.isWritable
126
+ * @returns {{ok: boolean, reason: string | null, transformVersion: string | null}}
127
+ */
128
+ export function verifyCacheEntry({ cacheDir, fileName, source, fsDeps }) {
129
+ const file = `${cacheDir}/${fileName}`;
130
+ if (!fsDeps.existsSync(file)) {
131
+ if (!fsDeps.isWritable(cacheDir)) {
132
+ return {
133
+ ok: false,
134
+ reason: `cache directory is not writable: ${cacheDir}`,
135
+ transformVersion: null,
136
+ };
137
+ }
138
+ // Deliberately generic. Several routes end here — a native import that
139
+ // needed no transform, a transform that threw before the write — and this
140
+ // check cannot tell them apart, so it does not name one.
141
+ return {
142
+ ok: false,
143
+ reason: `no cache entry was written: ${fileName}`,
144
+ transformVersion: null,
145
+ };
146
+ }
147
+ let body;
148
+ try {
149
+ body = fsDeps.readFileSync(file);
150
+ } catch (err) {
151
+ const message = err instanceof Error ? err.message : String(err);
152
+ return {
153
+ ok: false,
154
+ reason: `cache entry unreadable: ${message}`,
155
+ transformVersion: null,
156
+ };
157
+ }
158
+ const marker = /\/\* v([^-\s]+)-([0-9a-f]+) \*\/\s*$/.exec(body);
159
+ if (!marker) {
160
+ return {
161
+ ok: false,
162
+ reason: "cache entry has no jiti version marker — pi will re-transform",
163
+ transformVersion: null,
164
+ };
165
+ }
166
+ if (marker[2] !== jitiHash(source, marker[2].length)) {
167
+ return {
168
+ ok: false,
169
+ reason:
170
+ "cache entry does not match the built entry — pi will re-transform",
171
+ transformVersion: marker[1],
172
+ };
173
+ }
174
+ return { ok: true, reason: null, transformVersion: marker[1] };
175
+ }
176
+
177
+ /**
178
+ * Mirror of jiti's `prepareCacheDir` fallback branch.
179
+ *
180
+ * jiti picks `<dir of the file that created the jiti instance>/node_modules/
181
+ * .cache/jiti` when that `node_modules` exists, and `<tmpdir>/jiti` otherwise.
182
+ * pi creates its instance from `dist/core/extensions/loader.js` inside the
183
+ * installed `@earendil-works/pi-coding-agent`, which has no `node_modules`
184
+ * sibling, so pi always lands on the tmpdir branch. The warm therefore passes
185
+ * `<tmpdir>/jiti` EXPLICITLY rather than letting jiti derive a directory from
186
+ * this repo's layout: `scripts/` has no `node_modules` sibling today, but a
187
+ * script moved one level up would silently start filling a private cache that
188
+ * pi never reads.
189
+ *
190
+ * The TMPDIR dance below is jiti's, kept verbatim so the two agree on machines
191
+ * where TMPDIR is set to the current directory.
192
+ *
193
+ * @param {object} deps
194
+ * @param {() => string} deps.tmpdir
195
+ * @param {Record<string, string | undefined>} deps.env
196
+ * @param {() => string} deps.cwd
197
+ * @param {(a: string, b: string) => string} deps.join
198
+ * @returns {string}
199
+ */
200
+ export function resolveJitiCacheDir({ tmpdir, env, cwd, join }) {
201
+ let dir = tmpdir();
202
+ if (env.TMPDIR && dir === cwd() && !env.JITI_RESPECT_TMPDIR_ENV) {
203
+ const saved = env.TMPDIR;
204
+ delete env.TMPDIR;
205
+ dir = tmpdir();
206
+ env.TMPDIR = saved;
207
+ }
208
+ return join(dir, "jiti");
209
+ }
210
+
211
+ /**
212
+ * Alias every host-provided specifier to a path that cannot exist.
213
+ *
214
+ * The warm only needs the TRANSFORM of `dist/index.js`; jiti writes the cache
215
+ * entry before it evaluates the module. Evaluation then walks the bundle's
216
+ * external imports, none of which resolve outside pi. Pointing the
217
+ * host-provided ones at a stub makes that walk stop at the first one instead of
218
+ * resolving unrelated packages first, so the warm does the least work it can
219
+ * and always ends the same way. Derived from the shared host-provided list so
220
+ * it cannot drift from what `bundle-dist.mjs` keeps external.
221
+ *
222
+ * @param {readonly string[]} hostProvidedPackages
223
+ * @returns {Record<string, string>}
224
+ */
225
+ export function buildStubAliases(hostProvidedPackages) {
226
+ /** @type {Record<string, string>} */
227
+ const alias = {};
228
+ for (const name of hostProvidedPackages) {
229
+ alias[name] = STUB_TARGET;
230
+ }
231
+ return alias;
232
+ }
233
+
234
+ /** A path no filesystem resolves, so aliased imports fail immediately. */
235
+ export const STUB_TARGET = "/__pi-lens-warm-cache-stub__";
236
+
237
+ /**
238
+ * Decide whether the warm can run, and say why not when it cannot.
239
+ *
240
+ * Every reason is a skip, never a failure: `prepare` also builds `dist/` and
241
+ * downloads grammars, and those steps MUST fail loudly. Cache warming is an
242
+ * optimisation, so it runs last and reports rather than throws — the same
243
+ * posture as `scripts/setup-git-hooks.mjs` (#1804).
244
+ *
245
+ * @param {object} state
246
+ * @param {Record<string, string | undefined>} state.env
247
+ * @param {boolean} state.distEntryExists
248
+ * @param {boolean} state.jitiResolvable
249
+ * @returns {string | null} skip reason, or null to proceed
250
+ */
251
+ export function warmSkipReason({ env, distEntryExists, jitiResolvable }) {
252
+ const optOut = env.PI_LENS_SKIP_WARM_CACHE;
253
+ if (typeof optOut === "string" && optOut.length > 0) {
254
+ return "PI_LENS_SKIP_WARM_CACHE is set";
255
+ }
256
+ if (!distEntryExists) {
257
+ return "dist/index.js is missing — nothing to warm";
258
+ }
259
+ if (!jitiResolvable) {
260
+ // jiti is an optionalDependency, so `--omit=optional` (or a failed
261
+ // optional install) legitimately leaves it absent.
262
+ return "jiti is not installed — install ran without optional dependencies";
263
+ }
264
+ return null;
265
+ }
266
+
267
+ /**
268
+ * Keep the install log bounded: one line per install, newest last.
269
+ *
270
+ * @param {string[]} existingLines
271
+ * @param {string} line
272
+ * @param {number} [max]
273
+ * @returns {string[]}
274
+ */
275
+ export function appendBounded(
276
+ existingLines,
277
+ line,
278
+ max = INSTALL_LOG_MAX_LINES,
279
+ ) {
280
+ const lines = [...existingLines.filter((l) => l.trim().length > 0), line];
281
+ return lines.slice(Math.max(0, lines.length - max));
282
+ }
283
+
284
+ /** Cap for `~/.pi-lens/install.log`. */
285
+ export const INSTALL_LOG_MAX_LINES = 100;