pi-lens 4.4.2 → 4.4.4

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 (143) hide show
  1. package/CHANGELOG.md +306 -0
  2. package/README.md +13 -1
  3. package/dist/{chunk-VITUCP5A.js → chunk-24AKHFGP.js} +9 -7
  4. package/dist/{chunk-JSQFTSC6.js → chunk-2FN63LPV.js} +11 -11
  5. package/dist/{chunk-CF24OIYI.js → chunk-2KXTHF3M.js} +20 -20
  6. package/dist/{chunk-ESBQHFMJ.js → chunk-3DA2JDOL.js} +543 -349
  7. package/dist/{chunk-RWWYJ623.js → chunk-3KAZU3VR.js} +1 -1
  8. package/dist/chunk-3PTHXJXR.js +28 -0
  9. package/dist/chunk-3QED2P4V.js +31 -0
  10. package/dist/{chunk-N2H3ZPZY.js → chunk-3RKP3FMS.js} +1 -0
  11. package/dist/{chunk-XVF2VVXP.js → chunk-5HSRT26B.js} +2 -2
  12. package/dist/{chunk-XKIZ2HNN.js → chunk-5IUSNGOC.js} +18 -16
  13. package/dist/{chunk-EJIUXPBX.js → chunk-5OFV52AM.js} +1 -1
  14. package/dist/{chunk-AW7R2GWG.js → chunk-5VSF4UUQ.js} +12 -7
  15. package/dist/{chunk-NW7BOILN.js → chunk-5WRPDP2A.js} +18 -18
  16. package/dist/{chunk-7ULNCDCO.js → chunk-6DLWKFEX.js} +35 -34
  17. package/dist/{chunk-WOA4LN2R.js → chunk-7D6L4MTX.js} +2 -2
  18. package/dist/{chunk-GAEGEPOC.js → chunk-7HXE67BN.js} +2 -2
  19. package/dist/chunk-AGSS7277.js +84 -0
  20. package/dist/{chunk-R3PCY3YS.js → chunk-AMERCH6V.js} +13 -13
  21. package/dist/{chunk-WU567RSC.js → chunk-ATKJFDQN.js} +14 -10
  22. package/dist/{chunk-VSSBEUFT.js → chunk-AWSCPCN2.js} +16 -16
  23. package/dist/{chunk-VCHXCQFG.js → chunk-BWD5EPKO.js} +3 -3
  24. package/dist/{chunk-ODBNXWT4.js → chunk-CT4MN5IU.js} +1271 -262
  25. package/dist/{chunk-34WSVGEM.js → chunk-CUHSHSB6.js} +83 -5
  26. package/dist/{chunk-CHOW74WE.js → chunk-CYRKQRPM.js} +7 -10
  27. package/dist/{chunk-Q7DUK4LX.js → chunk-DEL2U6CM.js} +5 -5
  28. package/dist/{chunk-6C7JWPCP.js → chunk-DGUKIYSI.js} +26 -23
  29. package/dist/{chunk-PHDMFAKM.js → chunk-DUO2TU77.js} +2 -2
  30. package/dist/{chunk-MRXKYPCS.js → chunk-EBC36LNN.js} +2 -2
  31. package/dist/chunk-ED36RF6Y.js +17 -0
  32. package/dist/{chunk-76MWC2K6.js → chunk-EUXZEFTV.js} +90 -97
  33. package/dist/{chunk-QOZXJ4NA.js → chunk-FM4RDMKT.js} +11 -8
  34. package/dist/{chunk-66VMUL7Q.js → chunk-GAQ7OQG6.js} +31 -28
  35. package/dist/{chunk-Q6ZWTCLY.js → chunk-GL5LUMZE.js} +23 -24
  36. package/dist/{chunk-BKE7E7TC.js → chunk-GMX5OS7B.js} +9 -9
  37. package/dist/{chunk-ZHFZWTZV.js → chunk-GSLNCYGG.js} +11 -11
  38. package/dist/{chunk-ARJ6PLFH.js → chunk-GSM5UU7A.js} +26 -34
  39. package/dist/{chunk-724UL2TJ.js → chunk-GXFV6MYU.js} +3 -3
  40. package/dist/chunk-HAD7QRQI.js +30 -0
  41. package/dist/{chunk-QOBUHWHX.js → chunk-HRNHZA3N.js} +6 -6
  42. package/dist/{chunk-VWCXW2E7.js → chunk-HWCCCQNX.js} +1 -1
  43. package/dist/{chunk-7DYBLXYA.js → chunk-HYGZEMX6.js} +3 -3
  44. package/dist/chunk-IJYGIDF4.js +186 -0
  45. package/dist/{chunk-4W4VLAPJ.js → chunk-IQWN5JCP.js} +11 -11
  46. package/dist/{chunk-GZYBXISR.js → chunk-IUE7SSKV.js} +9 -9
  47. package/dist/{chunk-IBGIOBEE.js → chunk-IWDLU6K3.js} +68 -5
  48. package/dist/{chunk-XCJN5IJU.js → chunk-IYNJ6YPZ.js} +4 -4
  49. package/dist/{chunk-JAU72XNY.js → chunk-J64T55EI.js} +3 -3
  50. package/dist/{chunk-2BBLFX75.js → chunk-J6MPGQTS.js} +5 -5
  51. package/dist/chunk-JORED7EX.js +44 -0
  52. package/dist/{chunk-6SDEP3AJ.js → chunk-JUKMK5Y4.js} +8 -8
  53. package/dist/{chunk-IBRALRBR.js → chunk-JW3YWBVB.js} +15 -14
  54. package/dist/{chunk-L3BLXZG7.js → chunk-KAPJINKN.js} +30 -69
  55. package/dist/{chunk-2B73ZPOK.js → chunk-KN35K52N.js} +3 -3
  56. package/dist/{chunk-C34ZXZ7O.js → chunk-KUA4OJ5Y.js} +24 -11
  57. package/dist/chunk-KYUB6QDC.js +72 -0
  58. package/dist/{chunk-67WMNWEE.js → chunk-LLJNQKQW.js} +12 -10
  59. package/dist/chunk-LZSEQW6A.js +57 -0
  60. package/dist/{chunk-ZANLILYD.js → chunk-M2IFVFWU.js} +2 -2
  61. package/dist/chunk-M2UD3LPU.js +88 -0
  62. package/dist/{chunk-WOKKRCJ5.js → chunk-MBKQHGA7.js} +15 -14
  63. package/dist/{chunk-P7266HLM.js → chunk-MPNJLASA.js} +2 -2
  64. package/dist/{chunk-6GFRNVYR.js → chunk-MZGQNL3Y.js} +10 -10
  65. package/dist/{chunk-QZVW3AAK.js → chunk-N5FLEO7W.js} +33 -31
  66. package/dist/{chunk-3JNKMP3B.js → chunk-O4NWQT6I.js} +4 -4
  67. package/dist/{chunk-PFNSLWU5.js → chunk-OHKJ7PQF.js} +33 -31
  68. package/dist/{chunk-VRZP76YM.js → chunk-P63GXC37.js} +4 -1
  69. package/dist/{chunk-XTMJ7XDT.js → chunk-POMLXPMY.js} +1 -1
  70. package/dist/{chunk-QQNQ7SJW.js → chunk-QBFBRV2B.js} +10 -4
  71. package/dist/{chunk-3UUPUE3O.js → chunk-R3T7CLQ2.js} +84 -60
  72. package/dist/{chunk-23WOO7TX.js → chunk-R7YOCSNV.js} +10 -10
  73. package/dist/{chunk-2CD5RS6M.js → chunk-RKBXZN3A.js} +23 -2
  74. package/dist/{chunk-OG5UUPN7.js → chunk-RKKOYPRA.js} +14 -14
  75. package/dist/{chunk-52FUZI6K.js → chunk-RMLC67LF.js} +1 -1
  76. package/dist/{chunk-XMSXXAHW.js → chunk-ROYSX2S5.js} +2 -2
  77. package/dist/{chunk-PV6LR5P5.js → chunk-RVK72CZX.js} +9605 -14776
  78. package/dist/{chunk-FLKWCIZH.js → chunk-S2HEXIWI.js} +5 -5
  79. package/dist/{chunk-UZ3TUTRV.js → chunk-S67KYG37.js} +1 -1
  80. package/dist/{chunk-JWA5I3SR.js → chunk-SD4I7ZO4.js} +16 -18
  81. package/dist/{chunk-Z527N6YQ.js → chunk-SKAMFC7A.js} +12 -12
  82. package/dist/{chunk-MQLUAEOC.js → chunk-SRYEJNES.js} +2 -2
  83. package/dist/{chunk-BRGDSBJE.js → chunk-SYE6T2EB.js} +12 -12
  84. package/dist/{chunk-GXW6K4YJ.js → chunk-T57LYLZT.js} +1 -1
  85. package/dist/{chunk-ZXYDQ3TZ.js → chunk-T7FEBKXN.js} +48 -15
  86. package/dist/{chunk-IXXDFY73.js → chunk-TIQ2PKDW.js} +3 -3
  87. package/dist/{chunk-QILIYSJF.js → chunk-TLX72JTG.js} +1 -1
  88. package/dist/{chunk-TYM6OUIT.js → chunk-TOY6QKOH.js} +12 -12
  89. package/dist/{chunk-IZSHAIJ7.js → chunk-TRETHTQO.js} +12 -12
  90. package/dist/{chunk-3VPVG5SU.js → chunk-U44DJXQP.js} +10 -6
  91. package/dist/{chunk-PNQDPDRC.js → chunk-UA7ZXYDR.js} +1 -1
  92. package/dist/{chunk-OXXKQCSV.js → chunk-UBOQLVT2.js} +4 -4
  93. package/dist/{chunk-QGQ5CCCG.js → chunk-UMS2MFPZ.js} +2 -2
  94. package/dist/{chunk-QHJ6DQ2R.js → chunk-US7ECLD7.js} +55 -13
  95. package/dist/{chunk-EETY4LF7.js → chunk-UZ2XCLYS.js} +8 -8
  96. package/dist/chunk-V35JG4JB.js +2667 -0
  97. package/dist/{chunk-N3X6Q4YT.js → chunk-VOOAPROD.js} +3 -3
  98. package/dist/{chunk-SAEFPSB6.js → chunk-W66O5E25.js} +21 -11
  99. package/dist/{chunk-KBG47GHP.js → chunk-WMYE35PX.js} +15 -11
  100. package/dist/{chunk-5NGR67U5.js → chunk-XBJVAEEO.js} +32 -5
  101. package/dist/chunk-XHJBS436.js +4217 -0
  102. package/dist/{chunk-GBPP5P7X.js → chunk-XTBDCR27.js} +4 -4
  103. package/dist/{chunk-4UNXBNE2.js → chunk-XTFDJI3T.js} +11 -11
  104. package/dist/{chunk-UXGETRP4.js → chunk-XYURS6EN.js} +13 -12
  105. package/dist/{chunk-RPLINGVB.js → chunk-Y6GZHWSJ.js} +6 -6
  106. package/dist/{chunk-2ZGIZPDG.js → chunk-YCWYO6TQ.js} +41 -38
  107. package/dist/{chunk-66SXUJCA.js → chunk-YH5MVY2U.js} +10 -8
  108. package/dist/{chunk-AOHZU2BM.js → chunk-YNE3RP52.js} +1 -1
  109. package/dist/{chunk-GOYRYZ2R.js → chunk-ZHH37KZL.js} +72 -20
  110. package/dist/{chunk-ZGDGW2V3.js → chunk-ZIVJBOO3.js} +2 -2
  111. package/dist/{chunk-GHB6AYCP.js → chunk-ZVUL7QYY.js} +29 -28
  112. package/dist/index.js +11559 -6213
  113. package/dist/mcp/analyze-cli.js +10 -9
  114. package/dist/mcp/cli.js +41 -38
  115. package/dist/mcp/server.js +1142 -495
  116. package/dist/mcp/worker.js +61 -57
  117. package/dist/probes/installer.js +19 -19
  118. package/dist/workers/project-snapshot-persist-worker.js +28 -9
  119. package/dist/workers/review-graph-persist-worker.js +1 -1
  120. package/docs/configuration.md +11 -0
  121. package/docs/engineering-principles.md +32 -0
  122. package/docs/lsp-capability-matrix.md +1 -1
  123. package/docs/lsp-idle-eviction.md +15 -26
  124. package/docs/pi-lens-fixer.md +117 -13
  125. package/docs/pi-lens-merge-policy.md +109 -11
  126. package/docs/pi-lens-monitor.md +8 -2
  127. package/docs/pi-lens-retro.md +4 -1
  128. package/docs/pi-lens-reviewer.md +114 -3
  129. package/docs/pi-lens-subagent.md +27 -14
  130. package/docs/release-qa-baseline.md +3 -1
  131. package/docs/servercapabilities.md +9 -19
  132. package/package.json +7 -5
  133. package/scripts/download-grammars.js +40 -7
  134. package/scripts/lib/latency-log-phases.mjs +95 -0
  135. package/scripts/rpc-load-check.mjs +570 -81
  136. package/dist/chunk-3WA4HP4I.js +0 -55
  137. package/dist/chunk-466Z26MG.js +0 -30
  138. package/dist/chunk-5JM2524W.js +0 -84
  139. package/dist/chunk-CO2G5PHH.js +0 -84
  140. package/dist/chunk-LOP6GRPH.js +0 -31
  141. package/dist/chunk-UOPVJQ4N.js +0 -103
  142. package/dist/chunk-XPZPXCBV.js +0 -28
  143. /package/dist/{chunk-FC4RP4I2.js → chunk-6HWVI6MI.js} +0 -0
@@ -1,103 +1,592 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * RPC load check — positively verify pi-lens loaded, headless and model-free.
3
+ * RPC session smoke — drive pi-lens the way an RPC child runs it, headless and
4
+ * model-free, and red on ANY failure signal from start to the end of the first
5
+ * turn.
4
6
  *
5
- * Spawns `pi --mode rpc`, watches the JSONL event stream for `extension_error`,
6
- * then sends `get_commands` and asserts pi-lens's `lens-*` slash commands are
7
- * registered. `get_commands` does NOT call the LLM, so this needs no real
8
- * credentials — it is a deterministic POSITIVE check that pi actually loaded the
9
- * extension (vs. the weaker "no error + auth wall" signal).
7
+ * Recurrence this guard names: #4400 (pi exited at session start under
8
+ * `pi --mode rpc` when the snapshot persist worker failed; nobody had run pi as
9
+ * an RPC child with a failing worker) and #4314 (the 4.4.2 tarball failed the
10
+ * LSP warm in every session; this check passed because a failed warm is a log
11
+ * line, not an `extension_error`). Both shipped past a check that only waited
12
+ * for `get_commands`. The issue is #4401.
10
13
  *
11
- * Used by .github/workflows/install-smoke.yml (pi-load job) to confirm a
12
- * `pi install npm:pi-lens` under each package manager / install method really
13
- * loads. Exit 0 = pass, 1 = fail (load error or no pi-lens commands), 2 = timeout.
14
+ * What one run does (about 5 s warm, 12 s with a cold language-server install):
15
+ * 1. spawn `pi --mode rpc` with a PRIVATE agent dir and PI_LENS_HOME, a fake
16
+ * OpenAI-compatible provider on 127.0.0.1 (no credentials), and pi's own
17
+ * `defaultProjectTrust: "always"`, so the project is trusted and the LSP
18
+ * spawn and warm really run;
19
+ * 2. `get_commands`, and assert pi-lens's `lens-*` commands registered;
20
+ * 3. wait for the session-start warm-up (`warmup_total` in latency.log);
21
+ * 4. send a real `prompt` (text-only reply: no tool call runs), wait for
22
+ * `agent_end` and a clean `stop`;
23
+ * 5. read pi-lens's own logs under the pinned PI_LENS_HOME.
24
+ * It FAILS (exit 1) on any of: the pi process exiting, an `extension_error`
25
+ * event, a pi-lens notice at `warning` or `error` severity
26
+ * (`extension_ui_request` / `notify`), ANY `extension.log` line at `error` or
27
+ * `warn`, a latency.log phase named `*_failed` / `*_error`, a degraded
28
+ * snapshot-worker fallback, or a session that did not log
29
+ * `project trust = trusted`. Exit 2 = a stage timed out. On failure it prints
30
+ * the offending records in full and the log tails (the runner's scratch dies
31
+ * with the job), and retries once when a language-server `auto-install`
32
+ * failed (a registry blip).
33
+ *
34
+ * `--fail-worker` forces the project-snapshot persist worker to answer its
35
+ * first request with `{ id, generation, error }` and no `stagePath` (the #4400
36
+ * wire shape; the seam is PI_LENS_TEST_SNAPSHOT_WORKER_FAIL in
37
+ * clients/project-snapshot-persist-worker.ts, and it forces only that worker,
38
+ * not the review-graph one). Pi must survive, finish the turn
39
+ * and log the main-thread fallback for that exact reason; that one degraded
40
+ * fallback is the only failure signal the variant tolerates.
41
+ *
42
+ * `startRpcSession` is the one RPC driver: scripts/release-qa.mjs and
43
+ * scripts/compat-smoke-behavioral.mjs start pi through it too.
44
+ *
45
+ * Used by .github/workflows/install-smoke.yml (pi-load: the installed package;
46
+ * host-range-smoke: the packed tarball, plus the failing-worker variant).
14
47
  *
15
48
  * Usage: node scripts/rpc-load-check.mjs [path-to-pi-bin]
49
+ * [--extension <path-to-pi-lens>] [--fail-worker]
50
+ * no --extension: pi-lens comes from the ambient agent dir's `packages`
51
+ * (`pi install`), which this script links into its private agent dir.
16
52
  */
17
53
  import { spawn } from "node:child_process";
54
+ import * as fs from "node:fs";
55
+ import * as http from "node:http";
56
+ import * as os from "node:os";
57
+ import * as path from "node:path";
58
+ import { pathToFileURL } from "node:url";
59
+ import {
60
+ parseNdjsonEntries,
61
+ phaseWasLogged,
62
+ } from "./lib/latency-log-phases.mjs";
18
63
 
19
- const piBin = process.argv[2] || "pi";
20
- const pi = spawn(piBin, ["--mode", "rpc", "--no-session"], {
21
- stdio: ["pipe", "pipe", "inherit"],
22
- env: {
23
- ...process.env,
24
- // RPC needs a provider configured to start; get_commands never calls it.
25
- ANTHROPIC_API_KEY:
26
- process.env.ANTHROPIC_API_KEY || "sk-ant-dummy-rpc-load-check",
27
- },
28
- });
29
-
30
- let buf = "";
31
- const extErrors = [];
32
- let done = false;
33
- const finish = (code, msg) => {
34
- if (done) return;
35
- done = true;
36
- if (msg) console.log(msg);
37
- try {
38
- pi.kill("SIGKILL");
39
- } catch {}
40
- process.exit(code);
41
- };
42
- const timer = setTimeout(
43
- () => finish(2, "TIMEOUT waiting for get_commands response"),
44
- 30000,
45
- );
46
-
47
- function handle(line) {
48
- let m;
64
+ /**
65
+ * Where the TypeScript language server stands, from the two logs: started
66
+ * (`lsp_server_spawned` for `typescript` in latency.log), failed (a terminal
67
+ * `lsp spawn typescript: ...` line in sessionstart.log), or undefined while it
68
+ * is still spawning. The smoke project is TypeScript, so a started server is
69
+ * the positive evidence that the LSP leg ran (#4401 review: a refusal that
70
+ * logs after a fixed read never reached the check).
71
+ */
72
+ export function tsServerOutcome(latencyRecords, sessionstartText) {
73
+ const spawned = latencyRecords.find(
74
+ (r) =>
75
+ r.phase === "lsp_server_spawned" && r.metadata?.serverId === "typescript",
76
+ );
77
+ if (spawned) return { started: true };
78
+ const line = String(sessionstartText)
79
+ .split("\n")
80
+ .find((l) =>
81
+ /lsp spawn typescript: (unavailable|failed|refused|aborted|permanently disabled)/.test(
82
+ l,
83
+ ),
84
+ );
85
+ return line ? { started: false, line: line.trim() } : undefined;
86
+ }
87
+
88
+ /** Every executable `name` on PATH, in order (a runner-image tool can shadow the install). */
89
+ function commandsOnPath(name) {
90
+ const found = [];
91
+ for (const dir of (process.env.PATH ?? "").split(path.delimiter)) {
92
+ if (!dir) continue;
93
+ const file = path.join(dir, name);
94
+ try {
95
+ fs.accessSync(file, fs.constants.X_OK);
96
+ found.push(file);
97
+ } catch {
98
+ // not in this directory
99
+ }
100
+ }
101
+ return found;
102
+ }
103
+
104
+ /** The env var the failing-worker variant sets; the worker echoes it back. */
105
+ export const FORCE_WORKER_FAIL_ENV = "PI_LENS_TEST_SNAPSHOT_WORKER_FAIL";
106
+ /** The worker's forced error starts with this; the smoke keys its tolerance on it. */
107
+ export const FORCED_ERROR_MARKER = "forced snapshot worker failure";
108
+ /** Per-stage wait; the env override exists for the spawn test's timeout cases. */
109
+ const STAGE_TIMEOUT_MS =
110
+ Number(process.env.RPC_LOAD_CHECK_STAGE_TIMEOUT_MS) || 30000;
111
+ /** Notice severities (`notifyType`) that are failures; `info` is not. */
112
+ const FAILING_NOTIFY_TYPES = new Set(["warning", "error"]);
113
+ /** A latency.log phase named like a failure. */
114
+ const FAILING_PHASE = /_(failed|error)$/;
115
+
116
+ /** An `extension_error` in either shape pi has used. */
117
+ export function isExtensionError(message) {
118
+ return (
119
+ message?.type === "extension_error" ||
120
+ (message?.type === "event" && message.event === "extension_error")
121
+ );
122
+ }
123
+
124
+ /**
125
+ * Failure carried by one RPC stdout message, or undefined. Shapes (pi docs
126
+ * json.md / rpc-extension-ui.md): `{type:"extension_error",...}` (older hosts
127
+ * wrap it as `{type:"event",event:"extension_error"}`), and a notice is
128
+ * `{type:"extension_ui_request",method:"notify",message,notifyType}` with
129
+ * `notifyType` defaulting to `info`.
130
+ */
131
+ export function rpcMessageFailure(message) {
132
+ if (!message || typeof message !== "object") return undefined;
133
+ if (isExtensionError(message)) {
134
+ return `extension_error: ${String(message.error ?? "").slice(0, 300)} (${String(message.event ?? "")})`;
135
+ }
136
+ if (message.type === "extension_ui_request" && message.method === "notify") {
137
+ const severity = message.notifyType ?? "info";
138
+ if (FAILING_NOTIFY_TYPES.has(severity)) {
139
+ return `${severity} notice: ${String(message.message ?? "").slice(0, 300)}`;
140
+ }
141
+ }
142
+ return undefined;
143
+ }
144
+
145
+ /**
146
+ * Failure carried by one record of pi-lens's own logs, or undefined.
147
+ * `file` is `extension.log` (NDJSON, `level`) or `latency.log` (NDJSON,
148
+ * `type:"phase"`). `allowFallbackReason` is the one degraded worker fallback
149
+ * the failing-worker variant expects.
150
+ */
151
+ export function logRecordFailure(file, record, allowFallbackReason) {
152
+ if (!record || typeof record !== "object") return undefined;
153
+ if (file === "extension.log") {
154
+ if (record.level !== "error" && record.level !== "warn") return undefined;
155
+ return `extension.log ${record.level} [${record.subsystem}]: ${String(record.message ?? "").slice(0, 300)}`;
156
+ }
157
+ if (file === "latency.log" && record.type === "phase") {
158
+ const phase = String(record.phase ?? "");
159
+ if (phase === "project_snapshot_worker_fallback") {
160
+ if (record.metadata?.degraded !== true) return undefined;
161
+ const reason = String(record.metadata?.reason ?? "");
162
+ if (allowFallbackReason && reason.includes(allowFallbackReason)) {
163
+ return undefined;
164
+ }
165
+ return `latency.log degraded snapshot worker fallback: ${reason.slice(0, 300)}`;
166
+ }
167
+ if (FAILING_PHASE.test(phase)) {
168
+ return `latency.log phase ${phase}: ${JSON.stringify(record.metadata ?? {}).slice(0, 300)}`;
169
+ }
170
+ }
171
+ return undefined;
172
+ }
173
+
174
+ function readNdjson(file) {
49
175
  try {
50
- m = JSON.parse(line);
176
+ return parseNdjsonEntries(fs.readFileSync(file, "utf8"));
51
177
  } catch {
52
- return;
178
+ return [];
53
179
  }
54
- if (
55
- (m.type === "event" && m.event === "extension_error") ||
56
- m.type === "extension_error"
57
- ) {
58
- extErrors.push(m);
59
- console.log("extension_error:", JSON.stringify(m).slice(0, 300));
180
+ }
181
+
182
+ /** A minimal OpenAI-compatible streaming completion: one `ok` reply, no tools. */
183
+ function startFakeProvider() {
184
+ const state = { requests: 0 };
185
+ const server = http.createServer((req, res) => {
186
+ req.resume();
187
+ req.on("end", () => {
188
+ state.requests++;
189
+ res.writeHead(200, { "content-type": "text/event-stream" });
190
+ const chunk = (delta, finish) =>
191
+ `data: ${JSON.stringify({ id: "c1", object: "chat.completion.chunk", created: 1, model: "fake-model", choices: [{ index: 0, delta, finish_reason: finish ?? null }] })}\n\n`;
192
+ res.write(chunk({ role: "assistant", content: "ok" }));
193
+ res.write(chunk({}, "stop"));
194
+ res.write(
195
+ `data: ${JSON.stringify({ id: "c1", object: "chat.completion.chunk", created: 1, model: "fake-model", choices: [], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } })}\n\n`,
196
+ );
197
+ res.end("data: [DONE]\n\n");
198
+ });
199
+ });
200
+ return new Promise((resolve) => {
201
+ server.listen(0, "127.0.0.1", () =>
202
+ resolve({ server, state, port: server.address().port }),
203
+ );
204
+ });
205
+ }
206
+
207
+ /** Private agent dir: the fake provider, plus the ambient packages when no --extension. */
208
+ function writeAgentDir(agentDir, port, linkAmbientPackages) {
209
+ fs.mkdirSync(agentDir, { recursive: true });
210
+ fs.writeFileSync(
211
+ path.join(agentDir, "models.json"),
212
+ JSON.stringify({
213
+ providers: {
214
+ fake: {
215
+ baseUrl: `http://127.0.0.1:${port}/v1`,
216
+ api: "openai-completions",
217
+ apiKey: "fake",
218
+ models: [{ id: "fake-model" }],
219
+ },
220
+ },
221
+ }),
222
+ );
223
+ // pi's own setting: an RPC child has no UI to answer the trust prompt, and an
224
+ // untrusted project refuses the LSP spawn (the leg this smoke exists to run).
225
+ const settings = {
226
+ defaultProvider: "fake",
227
+ defaultModel: "fake-model",
228
+ defaultProjectTrust: "always",
229
+ };
230
+ if (linkAmbientPackages) {
231
+ const ambient =
232
+ process.env.PI_CODING_AGENT_DIR ||
233
+ path.join(os.homedir(), ".pi", "agent");
234
+ try {
235
+ const ambientSettings = JSON.parse(
236
+ fs.readFileSync(path.join(ambient, "settings.json"), "utf8"),
237
+ );
238
+ for (const key of ["packages", "npmCommand"]) {
239
+ if (ambientSettings[key] !== undefined)
240
+ settings[key] = ambientSettings[key];
241
+ }
242
+ } catch {
243
+ // no ambient settings: the command assertion below reports "did it load?"
244
+ }
245
+ for (const dir of ["npm", "git"]) {
246
+ try {
247
+ fs.symlinkSync(
248
+ path.join(ambient, dir),
249
+ path.join(agentDir, dir),
250
+ "dir",
251
+ );
252
+ } catch {
253
+ // no such ambient dir
254
+ }
255
+ }
60
256
  }
61
- if (m.type === "response" && m.command === "get_commands") {
62
- const cmds = (m.data && m.data.commands) || [];
63
- const lens = cmds.filter(
64
- (c) =>
65
- String(c.name || "").startsWith("lens-") ||
66
- String(c.path || "").includes("pi-lens"),
257
+ fs.writeFileSync(
258
+ path.join(agentDir, "settings.json"),
259
+ JSON.stringify(settings),
260
+ );
261
+ }
262
+
263
+ class StageTimeout extends Error {}
264
+
265
+ /**
266
+ * Spawn `pi` and frame its stdout as JSONL. The one RPC driver: this script's
267
+ * session smoke, release-qa's `get_commands` probe and the compat smoke's
268
+ * runs all start pi here, so the framing, the `extension_error` reading and
269
+ * the exit handling exist once (#4401; the two copies it replaced stopped at
270
+ * the first `get_commands` answer).
271
+ *
272
+ * `failures` holds every failure signal read off the wire (see
273
+ * `rpcMessageFailure`); `extensionErrors` only the `extension_error` events.
274
+ */
275
+ export function startRpcSession(
276
+ piBin,
277
+ { args, cwd, env, shell = false, stderr = "pipe" },
278
+ ) {
279
+ const child = spawn(piBin, args, {
280
+ cwd,
281
+ env,
282
+ shell,
283
+ stdio: ["pipe", "pipe", stderr],
284
+ });
285
+ const session = {
286
+ child,
287
+ failures: [],
288
+ extensionErrors: [],
289
+ stderrTail: [],
290
+ exited: undefined,
291
+ commandsResponse: undefined,
292
+ agentEnd: undefined,
293
+ /** Called with each parsed stdout message. */
294
+ onMessage: (fn) => messageListeners.push(fn),
295
+ /** Called once with the exit code. */
296
+ onExit: (fn) => exitListeners.push(fn),
297
+ send(message) {
298
+ try {
299
+ child.stdin.write(`${JSON.stringify(message)}\n`);
300
+ } catch {
301
+ // an exited pi is reported through `exited`
302
+ }
303
+ },
304
+ kill() {
305
+ try {
306
+ child.kill("SIGKILL");
307
+ } catch {}
308
+ },
309
+ };
310
+ const messageListeners = [];
311
+ const exitListeners = [];
312
+ let buf = "";
313
+ child.stderr?.on("data", (d) => {
314
+ session.stderrTail.push(d.toString());
315
+ if (session.stderrTail.length > 200) session.stderrTail.shift();
316
+ });
317
+ child.stdin.on("error", () => {
318
+ // a write to an exited pi; `exited` reports it
319
+ });
320
+ child.on("exit", (code, signal) => {
321
+ session.exited = { code, signal };
322
+ for (const fn of exitListeners) fn(code, signal);
323
+ });
324
+ child.stdout.on("data", (d) => {
325
+ // Strict JSONL: split on LF only (per pi RPC framing rules).
326
+ buf += d.toString();
327
+ let i = buf.indexOf("\n");
328
+ while (i >= 0) {
329
+ const line = buf.slice(0, i).replace(/\r$/, "");
330
+ buf = buf.slice(i + 1);
331
+ i = buf.indexOf("\n");
332
+ if (!line.trim()) continue;
333
+ let m;
334
+ try {
335
+ m = JSON.parse(line);
336
+ } catch {
337
+ continue;
338
+ }
339
+ const failure = rpcMessageFailure(m);
340
+ if (failure) session.failures.push(failure);
341
+ if (isExtensionError(m)) session.extensionErrors.push(m);
342
+ if (m.type === "response" && m.command === "get_commands")
343
+ session.commandsResponse = m;
344
+ if (m.type === "agent_end") session.agentEnd = m;
345
+ for (const fn of messageListeners) fn(m);
346
+ }
347
+ });
348
+ return session;
349
+ }
350
+
351
+ const tailOf = (text, lines) => text.split("\n").filter(Boolean).slice(-lines);
352
+
353
+ /**
354
+ * One session smoke. Resolves `{ code, installFailed }` and prints its verdict;
355
+ * code 0 pass, 1 failure signals, 2 a stage timed out.
356
+ */
357
+ export async function run(piBin, { extension, failWorker }) {
358
+ const scratch = fs.mkdtempSync(
359
+ path.join(os.tmpdir(), "pi-lens-rpc-session-smoke-"),
360
+ );
361
+ const agentDir = path.join(scratch, "agent");
362
+ const lensHome = path.join(scratch, "lens-home");
363
+ fs.mkdirSync(lensHome, { recursive: true });
364
+ const provider = await startFakeProvider();
365
+ writeAgentDir(agentDir, provider.port, !extension);
366
+
367
+ const env = {
368
+ ...process.env,
369
+ PI_CODING_AGENT_DIR: agentDir,
370
+ PI_LENS_HOME: lensHome,
371
+ PI_OFFLINE: "1",
372
+ PI_SKIP_VERSION_CHECK: "1",
373
+ };
374
+ // pi-lens writes no logs under a test runner; this smoke needs them.
375
+ delete env.VITEST;
376
+ if (env.NODE_ENV === "test") delete env.NODE_ENV;
377
+ if (failWorker) env[FORCE_WORKER_FAIL_ENV] = "no-stage-path";
378
+ else delete env[FORCE_WORKER_FAIL_ENV];
379
+
380
+ const args = ["--mode", "rpc", "--no-session"];
381
+ if (extension) args.push("-e", path.resolve(extension));
382
+ // Printed on every run: a PATH tsc changes which TypeScript compiler pi-lens finds.
383
+ console.log(`tsc on PATH: ${commandsOnPath("tsc").join(", ") || "none"}`);
384
+ const pi = startRpcSession(piBin, { args, env });
385
+ const failures = pi.failures;
386
+ const offending = [];
387
+
388
+ const stage = { name: "start" };
389
+ const waitFor = async (what, ms, probe) => {
390
+ stage.name = what;
391
+ const deadline = Date.now() + ms;
392
+ for (;;) {
393
+ if (pi.exited) return undefined;
394
+ const value = probe();
395
+ if (value) return value;
396
+ if (Date.now() > deadline)
397
+ throw new StageTimeout(`TIMEOUT waiting for ${what} (${ms}ms)`);
398
+ await new Promise((r) => setTimeout(r, 50));
399
+ }
400
+ };
401
+ const latency = () => readNdjson(path.join(lensHome, "latency.log"));
402
+ const readLog = (name) => {
403
+ try {
404
+ return fs.readFileSync(path.join(lensHome, name), "utf8");
405
+ } catch {
406
+ return "";
407
+ }
408
+ };
409
+ const forcedFallback = () =>
410
+ latency().find(
411
+ (r) =>
412
+ r.phase === "project_snapshot_worker_fallback" &&
413
+ String(r.metadata?.reason ?? "").includes(FORCED_ERROR_MARKER),
67
414
  );
68
- console.log(
69
- `total commands: ${cmds.length}; pi-lens commands: ${lens.map((c) => c.name).join(", ") || "(none)"}`,
415
+
416
+ let timedOut;
417
+ try {
418
+ // Give pi a moment to open stdin before the first command.
419
+ await new Promise((r) => setTimeout(r, 500));
420
+ pi.send({ id: "cmds", type: "get_commands" });
421
+ await waitFor(
422
+ "get_commands response",
423
+ STAGE_TIMEOUT_MS,
424
+ () => pi.commandsResponse,
70
425
  );
71
- clearTimeout(timer);
72
- if (extErrors.length)
73
- finish(1, `FAIL: ${extErrors.length} extension_error event(s)`);
74
- else if (!lens.length)
75
- finish(1, "FAIL: pi-lens registered no commands (did it load?)");
76
- else
77
- finish(
78
- 0,
79
- `PASS: pi-lens loaded — ${lens.length} lens-* commands registered`,
426
+ if (!pi.exited) {
427
+ const cmds = pi.commandsResponse?.data?.commands || [];
428
+ const lens = cmds.filter(
429
+ (c) =>
430
+ String(c.name || "").startsWith("lens-") ||
431
+ String(c.path || "").includes("pi-lens"),
80
432
  );
433
+ console.log(
434
+ `total commands: ${cmds.length}; pi-lens commands: ${lens.map((c) => c.name).join(", ") || "(none)"}`,
435
+ );
436
+ if (!lens.length)
437
+ failures.push("pi-lens registered no commands (did it load?)");
438
+ }
439
+ await waitFor(
440
+ "session-start warm-up (warmup_total in latency.log)",
441
+ STAGE_TIMEOUT_MS,
442
+ () => phaseWasLogged(latency(), "warmup_total"),
443
+ );
444
+ pi.send({ id: "turn", type: "prompt", message: "hello" });
445
+ await waitFor(
446
+ "first turn (agent_end)",
447
+ STAGE_TIMEOUT_MS,
448
+ () => pi.agentEnd,
449
+ );
450
+ if (pi.agentEnd && !pi.exited) {
451
+ const last = [...(pi.agentEnd.messages || [])]
452
+ .reverse()
453
+ .find((m) => m.role === "assistant");
454
+ if (!last || last.stopReason !== "stop")
455
+ failures.push(
456
+ `first turn did not finish cleanly: stopReason=${last?.stopReason ?? "(no assistant message)"}`,
457
+ );
458
+ if (provider.state.requests < 1)
459
+ failures.push("first turn never reached the fake provider");
460
+ }
461
+ // The LSP spawn is asynchronous: its refusal logs about a second after
462
+ // the turn ends (the PATH-compiler probe), so wait for a terminal state.
463
+ const tsServer = await waitFor(
464
+ "the TypeScript language server to start or fail (lsp_server_spawned in latency.log)",
465
+ STAGE_TIMEOUT_MS,
466
+ () => tsServerOutcome(latency(), readLog("sessionstart.log")),
467
+ );
468
+ if (tsServer && !tsServer.started)
469
+ failures.push(
470
+ `the TypeScript language server did not start: ${tsServer.line}`,
471
+ );
472
+ if (failWorker) {
473
+ await waitFor(
474
+ "the forced worker failure's main-thread fallback",
475
+ STAGE_TIMEOUT_MS / 2,
476
+ () => forcedFallback(),
477
+ );
478
+ } else {
479
+ // Post-turn work (the snapshot save after the turn) must settle too.
480
+ await new Promise((r) => setTimeout(r, 1000));
481
+ }
482
+ } catch (err) {
483
+ if (err instanceof StageTimeout) timedOut = err.message;
484
+ else throw err;
81
485
  }
82
- }
83
486
 
84
- pi.stdout.on("data", (d) => {
85
- // Strict JSONL: split on LF only (per pi RPC framing rules).
86
- buf += d.toString();
87
- let i;
88
- while ((i = buf.indexOf("\n")) >= 0) {
89
- const l = buf.slice(0, i).replace(/\r$/, "");
90
- buf = buf.slice(i + 1);
91
- if (l.trim()) handle(l);
487
+ if (pi.exited) {
488
+ failures.push(
489
+ `pi exited during "${stage.name}" (code ${pi.exited.code}, signal ${pi.exited.signal})`,
490
+ );
491
+ }
492
+ // The LSP spawn and the warm only run in a trusted project; an untrusted
493
+ // one would leave that leg dark and green.
494
+ const trustLine = tailOf(readLog("sessionstart.log"), 1e9).find((l) =>
495
+ l.includes("project trust = "),
496
+ );
497
+ if (!trustLine?.includes("project trust = trusted")) {
498
+ failures.push(
499
+ `smoke project is not trusted: the LSP spawn and warm are not exercised (${trustLine?.trim() ?? "no 'project trust' line in sessionstart.log"})`,
500
+ );
501
+ }
502
+ for (const r of readNdjson(path.join(lensHome, "extension.log"))) {
503
+ const f = logRecordFailure("extension.log", r);
504
+ if (f) {
505
+ failures.push(f);
506
+ offending.push(["extension.log", r]);
507
+ }
508
+ }
509
+ for (const r of latency()) {
510
+ const f = logRecordFailure(
511
+ "latency.log",
512
+ r,
513
+ failWorker ? FORCED_ERROR_MARKER : undefined,
514
+ );
515
+ if (f) {
516
+ failures.push(f);
517
+ offending.push(["latency.log", r]);
518
+ }
92
519
  }
93
- });
94
- pi.on("exit", (c) => {
95
- if (!done) finish(extErrors.length ? 1 : 2, `pi exited early (code ${c})`);
96
- });
97
520
 
98
- // Give extensions a moment to register, then request the command list.
99
- setTimeout(() => {
521
+ // Graceful stop (stdin EOF runs pi's own teardown, which ends any language
522
+ // server), then SIGKILL if it does not leave in 5 s.
100
523
  try {
101
- pi.stdin.write(`${JSON.stringify({ type: "get_commands" })}\n`);
524
+ pi.child.stdin.end();
102
525
  } catch {}
103
- }, 2500);
526
+ for (let waited = 0; !pi.exited && waited < 5000; waited += 50)
527
+ await new Promise((r) => setTimeout(r, 50));
528
+ pi.kill();
529
+ provider.server.close();
530
+ const unique = [...new Set(failures)];
531
+ // A registry blip fails the language-server install, and the failure then
532
+ // shows up as a cascade (a deno launch failure); name it and let main retry.
533
+ const installFailures = readLog("sessionstart.log")
534
+ .split("\n")
535
+ .filter((l) =>
536
+ /auto-install[^\n]*(fail|ECONN|ENOTFOUND|ETIMEDOUT|EAI_AGAIN)/i.test(l),
537
+ );
538
+ if (unique.length || timedOut) {
539
+ for (const f of unique) console.log(`FAIL: ${f}`);
540
+ if (timedOut) console.log(timedOut);
541
+ // The runner's scratch dies with the job: print what a reader needs.
542
+ for (const [file, record] of offending)
543
+ console.log(`offending record (${file}): ${JSON.stringify(record)}`);
544
+ for (const l of installFailures) console.log(`install failure: ${l}`);
545
+ for (const name of ["sessionstart.log", "extension.log"]) {
546
+ console.log(`--- ${name} (last 40 lines)`);
547
+ console.log(tailOf(readLog(name), 40).join("\n"));
548
+ }
549
+ if (pi.stderrTail.length)
550
+ console.log(`pi stderr (tail):\n${pi.stderrTail.join("").slice(-2000)}`);
551
+ console.log(`scratch kept for forensics: ${scratch}`);
552
+ return {
553
+ code: unique.length ? 1 : 2,
554
+ installFailed: installFailures.length > 0,
555
+ };
556
+ }
557
+ fs.rmSync(scratch, { recursive: true, force: true });
558
+ console.log(
559
+ `PASS: pi-lens loaded and survived start, warm-up and the first turn${failWorker ? " with the snapshot worker forced to fail" : ""}`,
560
+ );
561
+ return { code: 0, installFailed: false };
562
+ }
563
+
564
+ async function main(argv) {
565
+ const opts = { extension: undefined, failWorker: false };
566
+ const positional = [];
567
+ for (let i = 0; i < argv.length; i++) {
568
+ if (argv[i] === "--extension") opts.extension = argv[++i];
569
+ else if (argv[i] === "--fail-worker") opts.failWorker = true;
570
+ else positional.push(argv[i]);
571
+ }
572
+ const piBin = positional[0] || "pi";
573
+ let result = await run(piBin, opts);
574
+ if (result.code === 1 && result.installFailed) {
575
+ console.log("auto-install failed in the run above: retrying once");
576
+ result = await run(piBin, opts);
577
+ }
578
+ return result.code;
579
+ }
580
+
581
+ if (
582
+ process.argv[1] &&
583
+ import.meta.url === pathToFileURL(process.argv[1]).href
584
+ ) {
585
+ main(process.argv.slice(2)).then(
586
+ (code) => process.exit(code),
587
+ (err) => {
588
+ console.log(`ERROR: ${err?.stack || err}`);
589
+ process.exit(1);
590
+ },
591
+ );
592
+ }