ai-lens 0.8.98 → 0.8.99

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.
package/.commithash CHANGED
@@ -1 +1 @@
1
- 5d8b7f1
1
+ b23bf01
package/CHANGELOG.md CHANGED
@@ -2,6 +2,9 @@
2
2
 
3
3
  History of changes to the `ai-lens` CLI package on npm. New entries go on top. Format: `## X.Y.Z — YYYY-MM-DD`, followed by user-facing bullets.
4
4
 
5
+ ## 0.8.99 — 2026-06-17
6
+ - fix: Cursor hooks on Windows no longer flash a console window on every event. The flash was `node.exe` (a console app) allocating its own window when Cursor runs the hook; a new windowless launcher (`cursor-ai-lens-hook.ps1`) starts node with no window and forwards the event to it, so capture keeps working with no flash. Existing Cursor installs migrate automatically on the next `ai-lens init` / `/setup`. macOS/Linux and Claude Code are unchanged
7
+
5
8
  ## 0.8.98 — 2026-06-17
6
9
  - fix: Cursor hooks on Windows no longer use the `conhost.exe --headless` wrapper — it made Cursor report a "JSON Parse Error" on every hook (conhost emits terminal escape codes that Cursor tries to parse as the hook's output) and broke capture. Cursor returns to the plain working form; machines that briefly got the conhost'd Cursor hook are auto-downgraded on the next `ai-lens init`/`/setup`. Claude Code keeps the windowless wrapper (it tolerates the output). The minor Cursor console flash on Windows is upstream Cursor behaviour
7
10
 
package/cli/hooks.js CHANGED
@@ -369,24 +369,35 @@ export function cursorCaptureCommand(opts = {}) {
369
369
  const { useTilde = false, customPath = null } = opts;
370
370
  const ctx = opts.ctx ?? resolveDefaultCtx();
371
371
  const platform = ctx.platform ?? process.platform;
372
- // PowerShell doesn't expand ~ inside double-quoted strings — fall back to absolute.
373
- const effectiveUseTilde = platform === 'win32' ? false : useTilde;
374
- // Cursor on Windows runs hooks via PowerShell, so use the PS-compatible shell
375
- // hint when building the underlying command (avoids the cmd.exe `call` prefix
376
- // which PowerShell doesn't understand).
377
- const shell = platform === 'win32' ? 'powershell' : null;
378
- // NOTE: Cursor deliberately does NOT get the `conhost.exe --headless` windowless
379
- // wrapper (unlike Claude Code). conhost runs a ConPTY and emits VT escape sequences
380
- // (e.g. ESC[?9001h win32-input-mode) onto the hook's output stream, and Cursor
381
- // parses a hook's stdout as JSON → "JSON Parse Error … is not valid JSON", which
382
- // breaks the hook. Claude Code tolerates non-JSON hook stdout, so it keeps conhost.
383
- // The residual Cursor console flash on Windows is upstream (how Cursor spawns the
384
- // hook process) and is the lesser evil vs a broken hook. (ADR 0003 §3.2 deferred
385
- // Cursor+conhost as unverified; verified incompatible — reverted here.)
386
- const cmd = customPath != null
387
- ? captureCommand({ rawPath: true, customPath, ctx, shell })
388
- : captureCommand({ useTilde: effectiveUseTilde, ctx, shell });
389
- return platform === 'win32' ? `& ${cmd}` : cmd;
372
+
373
+ if (platform === 'win32') {
374
+ // Windows: route node through the windowless launcher cursor-ai-lens-hook.ps1.
375
+ // Cursor spawns hooks via a hidden powershell and pipes the payload through the
376
+ // PS pipeline ($input); launching node.exe directly there pops a console window
377
+ // on every event (node is console-subsystem, parent powershell has no console).
378
+ // conhost.exe --headless hides the window but corrupts Cursor's JSON-parse of hook
379
+ // stdout with ConPTY VT codes (verified). The launcher starts node with
380
+ // CreateNoWindow=$true and forwards $input node stdin: no window, clean stdout.
381
+ // Form: & "<...>/cursor-ai-lens-hook.ps1" "<absNode>" "<capture.js>"
382
+ const clientDir = (ctx.clientDir ?? CLIENT_INSTALL_DIR).replace(/\\/g, '/');
383
+ const nodeResolution = ctx.nodeResolution;
384
+ if (!nodeResolution || !nodeResolution.path) {
385
+ throw new Error('cursorCaptureCommand: nodeResolution is required');
386
+ }
387
+ const node = nodeResolution.path.replace(/\\/g, '/');
388
+ const capturePath = (customPath ?? `${clientDir}/capture.js`).replace(/\\/g, '/');
389
+ // The launcher ships next to capture.js (installed client dir, or the repo client
390
+ // dir for --use-repo-path) — derive its path from capturePath's directory.
391
+ const launcher = capturePath.replace(/[^/]*$/, 'cursor-ai-lens-hook.ps1');
392
+ const q = (s) => `"${s.replace(/"/g, '""')}"`;
393
+ return `& ${q(launcher)} ${q(node)} ${q(capturePath)}`;
394
+ }
395
+
396
+ // macOS / Linux: direct node, unchanged. (No console-window concept; conhost/launcher
397
+ // are Windows-only.)
398
+ return customPath != null
399
+ ? captureCommand({ rawPath: true, customPath, ctx })
400
+ : captureCommand({ useTilde, ctx });
390
401
  }
391
402
 
392
403
  // ---------------------------------------------------------------------------
@@ -401,7 +412,10 @@ export function cursorCaptureCommand(opts = {}) {
401
412
  * every hook with ERR_MODULE_NOT_FOUND on a missing sibling import.
402
413
  */
403
414
  export function listClientFiles(sourceDir = join(__dirname, '..', 'client')) {
404
- return readdirSync(sourceDir).filter(f => f.endsWith('.js')).sort();
415
+ // .js — all client modules (sibling imports must all be present).
416
+ // .ps1 — the Windows windowless Cursor hook launcher (cursor-ai-lens-hook.ps1);
417
+ // a missing launcher would break every Cursor hook on Windows, so ship it too.
418
+ return readdirSync(sourceDir).filter(f => f.endsWith('.js') || f.endsWith('.ps1')).sort();
405
419
  }
406
420
 
407
421
  /**
@@ -796,6 +810,17 @@ export function _parseHookCommand(cmd) {
796
810
  return { kind: 'launcher', path: first, prefix, nodePrefix: null };
797
811
  }
798
812
 
813
+ // Windows windowless Cursor launcher: & "<...>/cursor-ai-lens-hook.ps1" "<node>" "<capture.js>".
814
+ // node is the SECOND token (the launcher's first arg); capture.js is the third.
815
+ if (first.endsWith('cursor-ai-lens-hook.ps1')) {
816
+ return {
817
+ kind: 'cursorWindowless',
818
+ path: first,
819
+ prefix,
820
+ nodePrefix: tokens.length >= 2 ? normalizePath(tokens[1]) : null,
821
+ };
822
+ }
823
+
799
824
  if (tokens.length >= 2) {
800
825
  const second = normalizePath(tokens[1]);
801
826
  if (second.endsWith('capture.js')) {
@@ -853,10 +878,14 @@ function normalizePath(p) {
853
878
  // still classifies run.* as ai-lens so `remove`/strip cleans up stale launchers.)
854
879
  export function isGuiSafeHookCommand(cmd) {
855
880
  if (!isAiLensCommand(cmd).isAiLens) return false;
856
- // capture.js form: GUI-safe only when the node binary is an absolute path
857
- // (e.g. /opt/homebrew/bin/node), not bare `node` or an env shim.
881
+ // GUI-safe when an ABSOLUTE node path is baked in — either the direct capture.js form
882
+ // (`<absNode> capture.js`) or the Windows windowless launcher form
883
+ // (`& "<...>/cursor-ai-lens-hook.ps1" "<absNode>" "<capture.js>"`), where node is the
884
+ // launcher's first arg. Bare `node` / `/usr/bin/env node` are NOT GUI-safe (depend on
885
+ // PATH, which GUI Cursor/Claude often lack). The old run.sh/run.cmd launcher is NOT
886
+ // recognised here (ADR 0003) — it stays `outdated` so init migrates it.
858
887
  const p = _parseHookCommand(cmd);
859
- if (p.kind === 'captureJs' && p.nodePrefix) {
888
+ if ((p.kind === 'captureJs' || p.kind === 'cursorWindowless') && p.nodePrefix) {
860
889
  const np = p.nodePrefix;
861
890
  const isAbsolute = np.startsWith('/') || /^[a-zA-Z]:\//.test(np);
862
891
  const isEnvShim = np === '/usr/bin/env' || np.endsWith('/env');
@@ -1093,28 +1122,32 @@ function isCurrentAiLensHook(entry, expected, opts = {}) {
1093
1122
  // $CLAUDE_PROJECT_DIR/%CLAUDE_PROJECT_DIR% hook written for the OTHER OS won't
1094
1123
  // expand on this platform, so flag it outdated to let init rewrite it.
1095
1124
  //
1096
- // Windowless exception (same allowPlatformRewrite gate): flag a hook outdated when
1097
- // its stored `conhost.exe --headless` wrapper DISAGREES with the freshly regenerated
1098
- // `expected` command — in EITHER direction — so init re-syncs it:
1099
- // - expected has conhost, stored doesn't ADD it (Claude on conhost-capable
1100
- // Windows that predates the windowless form);
1101
- // - expected has NO conhost, stored DOES REMOVE it (Cursor: conhost is
1102
- // incompatible its ConPTY VT output breaks Cursor's JSON parse of hook stdout
1103
- // so we downgrade machines that got the short-lived conhost'd Cursor form).
1104
- // We compare ONLY the windowless dimension (not the whole command), so legitimate
1105
- // path/node/install-mode variation never false-flags. `expected` already bakes in
1106
- // tool+platform+conhost-support (makeClaudeHookDefs passes windowless:true;
1107
- // makeCursorHookDefs and makeCodexHookDefs do NOT), so this self-scopes: Codex,
1108
- // Cursor, macOS, and old Windows produce a non-conhost `expected`, and only Claude
1109
- // on conhost-capable Windows produces a conhost `expected`. Committed (tracked)
1110
- // files are exempt via allowPlatformRewrite — they carry one OS-agnostic syntax
1111
- // and can't bake a Windows-only wrapper; the per-machine overlay provides it.
1125
+ // Windows-wrapper exception (same allowPlatformRewrite gate): flag a hook outdated
1126
+ // when its stored windowless wrapper DISAGREES with the freshly regenerated `expected`
1127
+ // command, so init re-syncs it. We compare ONLY the wrapper kind — conhost vs the
1128
+ // Cursor `cursor-ai-lens-hook.ps1` launcher vs plain never the whole command, so
1129
+ // legitimate path/node/install-mode variation never false-flags. `expected` already
1130
+ // bakes in tool+platform: Claude on conhost-capable Windows`conhost`; Cursor on
1131
+ // Windows `cursorPs1` (the windowless launcher; conhost corrupts Cursor's stdout
1132
+ // JSON-parse); Codex, macOS, and old Windows `plain`. So this self-scopes per tool:
1133
+ // - Claude: stored plain outdated (add conhost); stored conhost current.
1134
+ // - Cursor: stored plain/conhost outdated (migrate to the .ps1 launcher); ps1 current.
1135
+ // - Codex/macOS: expected plain, stored plain → current (untouched).
1136
+ // Committed (tracked) files are exempt via allowPlatformRewrite they carry one
1137
+ // OS-agnostic syntax and can't bake a Windows-only wrapper; the per-machine overlay
1138
+ // (or the local .cursor/hooks.json) provides it.
1112
1139
  const { platform = process.platform, allowPlatformRewrite = false } = opts;
1113
1140
  const expectedCmd = expected?.command ?? expected?.hooks?.[0]?.command ?? '';
1114
- const expectedWindowless = isConhostHeadlessCommand(expectedCmd);
1141
+ const wrapperKind = (c) => {
1142
+ const s = String(c || '');
1143
+ if (isConhostHeadlessCommand(s)) return 'conhost';
1144
+ if (/cursor-ai-lens-hook\.ps1/i.test(s)) return 'cursorPs1';
1145
+ return 'plain';
1146
+ };
1147
+ const expectedWrapper = wrapperKind(expectedCmd);
1115
1148
  const ok = (cmd) => isAcceptableHookCommand(cmd)
1116
1149
  && !(allowPlatformRewrite && isWrongPlatformProjectDirCommand(cmd, platform))
1117
- && !(allowPlatformRewrite && expectedWindowless !== isConhostHeadlessCommand(cmd));
1150
+ && !(allowPlatformRewrite && wrapperKind(cmd) !== expectedWrapper);
1118
1151
  // Flat format (Cursor): single command per entry.
1119
1152
  if (entry?.command != null) {
1120
1153
  return ok(entry.command);
package/cli/status.js CHANGED
@@ -150,8 +150,28 @@ function validateHookCommandPaths(tool) {
150
150
 
151
151
  const issues = [];
152
152
  const launcherMatch = command.match(/["']([^"']*run\.(?:sh|cmd))["']|(\S*run\.(?:sh|cmd))/);
153
-
154
- if (launcherMatch) {
153
+ const ps1Match = command.match(/["']([^"']*cursor-ai-lens-hook\.ps1)["']|(\S*cursor-ai-lens-hook\.ps1)/);
154
+
155
+ if (ps1Match) {
156
+ // Windows windowless Cursor launcher: & "<...>/cursor-ai-lens-hook.ps1" "<node>" "<capture.js>".
157
+ // Validate the launcher, the node arg (2nd token), and capture.js (3rd token) all exist.
158
+ const ps1Path = expandTilde(ps1Match[1] || ps1Match[2]);
159
+ if (!existsSync(ps1Path)) issues.push(`Cursor hook launcher not found at: ${ps1Path}`);
160
+ const capMatch = command.match(/["']([^"']*capture\.js)["']|(\S*capture\.js)/);
161
+ if (capMatch) {
162
+ const capturePath = expandTilde(capMatch[1] || capMatch[2]);
163
+ if (!existsSync(capturePath)) issues.push(`capture.js not found at: ${capturePath}`);
164
+ }
165
+ // node = the launcher's first argument (token right after the .ps1 path)
166
+ const nodeArg = command.replace(/^& /, '')
167
+ .match(/cursor-ai-lens-hook\.ps1["']?\s+(?:["']([^"']+)["']|(\S+))/);
168
+ if (nodeArg) {
169
+ const nodePath = expandTilde(nodeArg[1] || nodeArg[2]);
170
+ if (nodePath !== 'node' && !existsSync(nodePath)) {
171
+ issues.push(`node not found at: ${nodePath} — re-run \`ai-lens init\` to re-resolve node`);
172
+ }
173
+ }
174
+ } else if (launcherMatch) {
155
175
  // Launcher form (0.8.68+): one path, no separate node validation — the node binary
156
176
  // baked into the launcher is invoked from inside the script, not the hook command.
157
177
  const launcherPath = expandTilde(launcherMatch[1] || launcherMatch[2]);
@@ -0,0 +1,32 @@
1
+ # AI Lens — Cursor windowless hook launcher (Windows only).
2
+ #
3
+ # Why this exists: Cursor runs a hook by spawning a (hidden) powershell that pipes the
4
+ # event payload through the PowerShell pipeline ($input) — e.g.
5
+ # Get-Content <tmp>.json -Raw | & { $input | & "<this.ps1>" "<node>" "<capture.js>" }
6
+ # Launching node.exe directly there pops a console window on every event (node is a
7
+ # console-subsystem binary and the parent powershell has no console). conhost.exe
8
+ # --headless hides the window but emits ConPTY VT escape codes onto stdout, which Cursor
9
+ # then fails to JSON-parse. This launcher instead starts node with CreateNoWindow=$true
10
+ # (no window) while forwarding the payload to node's stdin — so capture still works and
11
+ # nothing extra is written to stdout.
12
+ #
13
+ # Args: $args[0] = absolute node path, $args[1] = capture.js path.
14
+ # Payload arrives on the PowerShell pipeline as $input (NOT [Console]::In, which is empty
15
+ # here because the data is an in-process pipeline object, not the process's OS stdin).
16
+
17
+ $ErrorActionPreference = 'Stop'
18
+ $node = $args[0]
19
+ $capture = $args[1]
20
+ $payload = @($input) -join "`n"
21
+
22
+ $psi = New-Object System.Diagnostics.ProcessStartInfo
23
+ $psi.FileName = $node
24
+ $psi.Arguments = '"' + $capture + '"'
25
+ $psi.UseShellExecute = $false
26
+ $psi.CreateNoWindow = $true
27
+ $psi.RedirectStandardInput = $true
28
+
29
+ $proc = [System.Diagnostics.Process]::Start($psi)
30
+ $proc.StandardInput.Write($payload)
31
+ $proc.StandardInput.Close()
32
+ $proc.WaitForExit()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-lens",
3
- "version": "0.8.98",
3
+ "version": "0.8.99",
4
4
  "type": "module",
5
5
  "description": "Centralized session analytics for AI coding tools",
6
6
  "bin": {