ai-lens 0.8.97 → 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
- 13ab9a5
1
+ b23bf01
package/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
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
+
8
+ ## 0.8.98 — 2026-06-17
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
10
+
5
11
  ## 0.8.97 — 2026-06-17
6
12
  - fix: `ai-lens init` no longer leaves an invalid `{ "version": 1 }` (no hooks) in `~/.cursor/hooks.json` when migrating to project hooks — Cursor flagged such a file as an error and stopped running ALL hooks, silently killing capture even when project hooks were correct. init now deletes the empty file, and repairs machines already left in this state on the next run
7
13
 
package/cli/hooks.js CHANGED
@@ -369,20 +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
- // windowless: on Windows ≥1809 wrap node in `conhost.exe --headless` so Cursor
379
- // doesn't flash a console window on every event (incl. session start). `& ` still
380
- // works in front of conhost; the detector strips `& ` then the conhost prefix, so
381
- // the form stays recognised as current (no churn). Mac/old-Windows: unchanged.
382
- const cmd = customPath != null
383
- ? captureCommand({ rawPath: true, customPath, windowless: true, ctx, shell })
384
- : captureCommand({ useTilde: effectiveUseTilde, windowless: true, ctx, shell });
385
- 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 });
386
401
  }
387
402
 
388
403
  // ---------------------------------------------------------------------------
@@ -397,7 +412,10 @@ export function cursorCaptureCommand(opts = {}) {
397
412
  * every hook with ERR_MODULE_NOT_FOUND on a missing sibling import.
398
413
  */
399
414
  export function listClientFiles(sourceDir = join(__dirname, '..', 'client')) {
400
- 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();
401
419
  }
402
420
 
403
421
  /**
@@ -792,6 +810,17 @@ export function _parseHookCommand(cmd) {
792
810
  return { kind: 'launcher', path: first, prefix, nodePrefix: null };
793
811
  }
794
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
+
795
824
  if (tokens.length >= 2) {
796
825
  const second = normalizePath(tokens[1]);
797
826
  if (second.endsWith('capture.js')) {
@@ -849,10 +878,14 @@ function normalizePath(p) {
849
878
  // still classifies run.* as ai-lens so `remove`/strip cleans up stale launchers.)
850
879
  export function isGuiSafeHookCommand(cmd) {
851
880
  if (!isAiLensCommand(cmd).isAiLens) return false;
852
- // capture.js form: GUI-safe only when the node binary is an absolute path
853
- // (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.
854
887
  const p = _parseHookCommand(cmd);
855
- if (p.kind === 'captureJs' && p.nodePrefix) {
888
+ if ((p.kind === 'captureJs' || p.kind === 'cursorWindowless') && p.nodePrefix) {
856
889
  const np = p.nodePrefix;
857
890
  const isAbsolute = np.startsWith('/') || /^[a-zA-Z]:\//.test(np);
858
891
  const isEnvShim = np === '/usr/bin/env' || np.endsWith('/env');
@@ -1089,25 +1122,32 @@ function isCurrentAiLensHook(entry, expected, opts = {}) {
1089
1122
  // $CLAUDE_PROJECT_DIR/%CLAUDE_PROJECT_DIR% hook written for the OTHER OS won't
1090
1123
  // expand on this platform, so flag it outdated to let init rewrite it.
1091
1124
  //
1092
- // Windowless exception (same allowPlatformRewrite gate): when the freshly
1093
- // regenerated `expected` command carries the `conhost.exe --headless` wrapper
1094
- // (Cursor/Claude on conhost-capable Windows) but the stored command does NOT,
1095
- // flag it outdated so init upgrades it otherwise a pre-conhost install stays
1096
- // `current` forever and keeps flashing a console window on every event. We
1097
- // compare ONLY the windowless dimension (not the whole command), so legitimate
1098
- // path/node/install-mode variation never false-flags. `expected` already bakes
1099
- // in tool+platform+conhost-support (makeCursorHookDefs/makeClaudeHookDefs pass
1100
- // windowless:true; makeCodexHookDefs does not), so this self-scopes: Codex,
1101
- // macOS, and old Windows produce a non-conhost `expected` and never trip 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 plainnever 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).
1102
1136
  // Committed (tracked) files are exempt via allowPlatformRewrite — they carry one
1103
- // OS-agnostic syntax and can't bake a Windows-only wrapper; the per-machine
1104
- // overlay provides the windowless path.
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.
1105
1139
  const { platform = process.platform, allowPlatformRewrite = false } = opts;
1106
1140
  const expectedCmd = expected?.command ?? expected?.hooks?.[0]?.command ?? '';
1107
- 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);
1108
1148
  const ok = (cmd) => isAcceptableHookCommand(cmd)
1109
1149
  && !(allowPlatformRewrite && isWrongPlatformProjectDirCommand(cmd, platform))
1110
- && !(allowPlatformRewrite && expectedWindowless && !isConhostHeadlessCommand(cmd));
1150
+ && !(allowPlatformRewrite && wrapperKind(cmd) !== expectedWrapper);
1111
1151
  // Flat format (Cursor): single command per entry.
1112
1152
  if (entry?.command != null) {
1113
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.97",
3
+ "version": "0.8.99",
4
4
  "type": "module",
5
5
  "description": "Centralized session analytics for AI coding tools",
6
6
  "bin": {