ai-lens 0.8.92 → 0.8.94

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
- 2196993
1
+ 1b043cc
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.94 — 2026-06-16
6
+ - feat: capture 8 more Claude Code events that were previously ignored — turn failures (session-limit / prompt-too-long / server-overload messages), notifications, permission requests, post-compact summaries, task created/completed, instructions loaded, and slash-command expansions. Re-run `ai-lens init` to install them; they're collected and forwarded only (no dashboard changes yet)
7
+
8
+ ## 0.8.93 — 2026-06-15
9
+ - fix: on Windows, committed Claude Code project hooks no longer flash a console window that steals focus on every event — the hook command is wrapped in `conhost.exe --headless` (Windows 10 1809+), which runs capture with no visible window while still capturing every event. Older Windows builds and macOS/Linux are unchanged
10
+
5
11
  ## 0.8.92 — 2026-06-11
6
12
  - fix: when global `~/.claude` hooks are active, `ai-lens init` no longer also installs the `.claude/settings.local.json` platform overlay (and removes a previously-written one) — global hooks already capture every project, and registering both made every hook event fire capture twice
7
13
  - improve: `ai-lens status` shows committed other-OS project hooks as healthy ("capture covered by global hooks") when global hooks are doing the work, instead of a false warning
package/cli/hooks.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { existsSync, lstatSync, readFileSync, writeFileSync, copyFileSync, renameSync, mkdirSync, rmSync, unlinkSync, chmodSync, readdirSync } from 'node:fs';
2
2
  import { join, dirname } from 'node:path';
3
- import { homedir } from 'node:os';
3
+ import { homedir, release } from 'node:os';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { execFileSync } from 'node:child_process';
6
6
 
@@ -230,78 +230,31 @@ export function findStableNodePath({
230
230
  return { path: execPath, stable: false, source: 'execPathFallback' };
231
231
  }
232
232
 
233
+ // ADR 0003 removed the per-machine launcher (run.sh / run.cmd). The absolute node
234
+ // path is now baked straight into the hook command by captureCommand; there is no
235
+ // launcher script. `isAiLensCommand` still recognises run.* so `remove`/migration
236
+ // can clean up installs from before this change.
237
+
233
238
  // ---------------------------------------------------------------------------
234
- // Per-machine launcher (run.sh / run.cmd)
239
+ // Hook command construction
235
240
  // ---------------------------------------------------------------------------
236
241
 
237
- /**
238
- * Filename of the launcher in the client directory.
239
- */
240
- export function launcherFilename(platform = process.platform) {
241
- return platform === 'win32' ? 'run.cmd' : 'run.sh';
242
- }
243
-
244
- /**
245
- * Write a per-machine launcher script with the resolved node path baked in.
246
- *
247
- * The launcher acts as a node-resolution shim that supports two invocation modes:
248
- *
249
- * 1. No arguments — run the sibling capture.js (the default install flow where
250
- * both the launcher and capture.js live in ~/.ai-lens/client/).
251
- * 2. Script path as $1 — run that script with the resolved node. This is the
252
- * repo-path mode (e.g. meta-cursor static hooks pointing at
253
- * `internal/analytics/ai-lens/client/capture.js`) where capture.js auto-
254
- * updates via `git pull` and only the node binary needs per-machine baking.
255
- *
256
- * In both cases the launcher is independent of HOME / USERPROFILE / cwd — for
257
- * mode 1 the path is derived from `dirname $0`; for mode 2 it's whatever the
258
- * caller passes through (typically a workspace-relative path that resolves
259
- * against the cwd Cursor/Claude Code set when firing the hook).
260
- *
261
- * @param {object} opts
262
- * @param {string} opts.clientDir — directory where the launcher (and capture.js) live
263
- * @param {string} opts.nodePath — absolute path to the node binary to bake in
264
- * @param {string} [opts.platform]
265
- */
266
- export function writeLauncher({ clientDir = CLIENT_INSTALL_DIR, nodePath, platform = process.platform } = {}) {
267
- if (!nodePath) throw new Error('writeLauncher: nodePath is required');
268
- if (!clientDir) throw new Error('writeLauncher: clientDir is required');
242
+ // `conhost.exe --headless` runs a console program with no visible window — the
243
+ // fix for Windows hooks flashing/stealing focus on every event (Claude Code spawns
244
+ // the hook process without CREATE_NO_WINDOW; upstream anthropics/claude-code#61051).
245
+ // Unlike wscript/`-WindowStyle Hidden`, it keeps stdin wired, so capture.js still
246
+ // receives the hook payload. `--headless` requires the modern conhost shipped in
247
+ // Windows 10 1809 (build 17763); on older builds the flag errors, so we gate on it
248
+ // and fall back to the bare-node form (still flashes, but captures).
249
+ const WIN_HEADLESS_MIN_BUILD = 17763;
269
250
 
270
- if (platform === 'win32') {
271
- const escaped = nodePath.replace(/"/g, '""');
272
- const content =
273
- '@echo off\r\n'
274
- + 'if "%~1"=="" goto default\r\n'
275
- + `"${escaped}" %*\r\n`
276
- + 'goto :eof\r\n'
277
- + ':default\r\n'
278
- + `"${escaped}" "%~dp0capture.js"\r\n`;
279
- const target = join(clientDir, 'run.cmd');
280
- writeFileSync(target, content);
281
- return target;
282
- }
283
-
284
- const escaped = shellEscape(nodePath, 'linux'); // POSIX single-quote escape
285
- const content =
286
- '#!/bin/sh\n'
287
- + `# AI Lens per-machine launcher. Two modes:\n`
288
- + `# $0 → exec node on sibling capture.js (default install)\n`
289
- + `# $0 <script> → exec node on <script> with remaining args (repo-path mode)\n`
290
- + 'if [ $# -gt 0 ]; then\n'
291
- + ` exec ${escaped} "$@"\n`
292
- + 'fi\n'
293
- + 'DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)\n'
294
- + `exec ${escaped} "$DIR/capture.js"\n`;
295
- const target = join(clientDir, 'run.sh');
296
- writeFileSync(target, content);
297
- try { chmodSync(target, 0o755); } catch { /* best effort */ }
298
- return target;
251
+ export function supportsConhostHeadless(osRelease = release()) {
252
+ // os.release() on Windows looks like '10.0.22631'. Build = the third segment.
253
+ const m = /^\d+\.\d+\.(\d+)/.exec(String(osRelease || ''));
254
+ if (!m) return false;
255
+ return Number(m[1]) >= WIN_HEADLESS_MIN_BUILD;
299
256
  }
300
257
 
301
- // ---------------------------------------------------------------------------
302
- // Hook command construction
303
- // ---------------------------------------------------------------------------
304
-
305
258
  // Lazy default ctx for callers that don't pass one (legacy tests, TOOL_CONFIGS at
306
259
  // import-time). Resolver isn't called at module load — only when captureCommand runs.
307
260
  function resolveDefaultCtx() {
@@ -310,6 +263,7 @@ function resolveDefaultCtx() {
310
263
  nodeResolution,
311
264
  platform: process.platform,
312
265
  clientDir: CLIENT_INSTALL_DIR,
266
+ conhost: process.platform === 'win32' && supportsConhostHeadless(),
313
267
  };
314
268
  }
315
269
 
@@ -332,7 +286,8 @@ export function captureCommand(opts = {}) {
332
286
  if (typeof opts === 'boolean') {
333
287
  opts = { useTilde: opts, rawPath: arguments[1], customPath: arguments[2] ?? null };
334
288
  }
335
- const { useTilde = false, rawPath = false, customPath = null, shell = null, projectDirRelPath = null } = opts;
289
+ const { useTilde = false, rawPath = false, customPath = null, shell = null, projectDirRelPath = null, windowless = false } = opts;
290
+ void useTilde; // launcher form removed (ADR 0003); kept in the signature for back-compat, no effect
336
291
  const ctx = opts.ctx ?? resolveDefaultCtx();
337
292
  // Tolerate partial ctx (e.g. only nodeResolution supplied) by filling in module
338
293
  // defaults — keeps callers from having to repeat platform/clientDir everywhere.
@@ -340,6 +295,11 @@ export function captureCommand(opts = {}) {
340
295
  const clientDir = ctx.clientDir ?? CLIENT_INSTALL_DIR;
341
296
  const nodeResolution = ctx.nodeResolution;
342
297
  const isWin = platform === 'win32';
298
+ // Windows-only windowless wrapper (see supportsConhostHeadless). Gated by the
299
+ // `windowless` opt so it's applied to CLAUDE forms only — Cursor/Codex never set it
300
+ // (no reported flash + unverified conhost+stdin for those tools; ADR 0003 §3.2).
301
+ // A build < 1809 falls back to the bare form. Never applied off Windows.
302
+ const conhost = isWin && windowless && (ctx.conhost ?? supportsConhostHeadless());
343
303
  // shell hint distinguishes cmd.exe (Claude Code) from PowerShell (Cursor) on
344
304
  // Windows — the two need different escaping for paths with spaces.
345
305
  const isPS = shell === 'powershell';
@@ -354,65 +314,49 @@ export function captureCommand(opts = {}) {
354
314
  // POSIX shells use $VAR.
355
315
  if (projectDirRelPath != null) {
356
316
  const rel = projectDirRelPath.replace(/\\/g, '/').replace(/^\.?\/+/, '');
317
+ if (conhost) {
318
+ // Windowless form. conhost is the launched image (no cmd.exe shell), so the
319
+ // path must use $CLAUDE_PROJECT_DIR — Claude Code substitutes that variable
320
+ // itself before exec on every OS (the %VAR% form would survive unexpanded
321
+ // since there's no shell to expand it). Verified on Windows 11 / cc 2.1.177.
322
+ return `conhost.exe --headless node "$CLAUDE_PROJECT_DIR/${rel}"`;
323
+ }
357
324
  const dir = isWin ? '%CLAUDE_PROJECT_DIR%' : '$CLAUDE_PROJECT_DIR';
358
325
  return `node "${dir}/${rel}"`;
359
326
  }
360
327
 
361
- // --use-repo-path: legacy <node> <capture.js> form.
362
- if (customPath != null) {
363
- if (!nodeResolution || !nodeResolution.path) {
364
- throw new Error('captureCommand: nodeResolution is required for --use-repo-path');
365
- }
366
- const capturePath = customPath.replace(/\\/g, '/');
367
- // Even in rawPath mode (Claude Code "do not unconditionally wrap"), the script
368
- // path MUST be quoted when it contains spaces — otherwise /bin/sh, cmd.exe and
369
- // PowerShell all split it into separate argv tokens (e.g. `/Users/foo bar/...`).
370
- // Use shell-appropriate quoting: double-quote on Windows (works for both
371
- // cmd.exe and PowerShell), POSIX single-quote elsewhere.
372
- const pathPart = rawPath
373
- ? quoteIfHasSpaces(capturePath, platform)
374
- : shellEscape(capturePath, platform);
375
- const nodeNorm = nodeResolution.path.replace(/\\/g, '/');
376
- if (isWin && rawPath && nodeNorm.includes(' ')) {
377
- const quotedNode = `"${nodeNorm.replace(/"/g, '""')}"`;
378
- // PowerShell (Cursor) handles a quoted first token natively caller will
379
- // prepend `& `, giving `& "<node>" <path>`.
380
- if (isPS) return `${quotedNode} ${pathPart}`;
381
- // cmd.exe (Claude Code) doesn't always treat a quoted first token as a
382
- // command — `call` is the canonical workaround. Avoids the previous bare-
383
- // `node` fallback that depended on PATH (C:\Program Files\nodejs\node.exe
384
- // wouldn't be reachable from launchd-like environments).
385
- return `call ${quotedNode} ${pathPart}`;
386
- }
387
- return `${shellEscape(nodeNorm, platform)} ${pathPart}`;
388
- }
389
-
390
- // Launcher form. Path inside the command — tilde for project-hooks portability on POSIX;
391
- // absolute on Windows (PowerShell/cmd don't expand ~).
392
- const filename = launcherFilename(platform);
393
- let launcherPath;
394
- if (useTilde && !isWin) {
395
- launcherPath = `~/.ai-lens/client/${filename}`;
396
- } else {
397
- launcherPath = join(clientDir, filename).replace(/\\/g, '/');
398
- }
399
-
400
- // POSIX: quote the launcher path unless it's the unquoted tilde form (which must
401
- // remain unquoted for shell tilde expansion). rawPath (Claude Code) leaves the
402
- // tilde form bare too — Claude Code passes commands to /bin/sh which expands ~.
403
- if (!isWin) {
404
- if (useTilde) return launcherPath; // unquoted ~/path
405
- return shellEscape(launcherPath, 'linux');
406
- }
407
-
408
- // Windows: cmd.exe (Claude Code, rawPath=true) needs `call "..."` when the
409
- // launcher path contains spaces; otherwise plain quoted path works.
410
- // PowerShell (Cursor) handles `"..."` via the & prefix (added by cursorCaptureCommand).
411
- const quoted = `"${launcherPath.replace(/"/g, '""')}"`;
412
- if (rawPath && launcherPath.includes(' ') && !isPS) {
413
- return `call ${quoted}`;
414
- }
415
- return quoted;
328
+ // Unified <node> <capture.js> form (ADR 0003 — replaces the run.sh/run.cmd launcher).
329
+ // capturePath = the repo capture.js (--use-repo-path, customPath given) OR the
330
+ // installed copy at <clientDir>/capture.js (default global install). The absolute
331
+ // node path is baked straight into the command — legal now that hooks are
332
+ // per-machine (no committed file to keep machine-agnostic).
333
+ if (!nodeResolution || !nodeResolution.path) {
334
+ throw new Error('captureCommand: nodeResolution is required');
335
+ }
336
+ const capturePath = (customPath ?? join(clientDir, 'capture.js')).replace(/\\/g, '/');
337
+ // The script path MUST be quoted when it contains spaces — otherwise /bin/sh,
338
+ // cmd.exe and PowerShell split it into argv tokens. Double-quote on Windows
339
+ // (works for cmd.exe + PowerShell), POSIX single-quote elsewhere.
340
+ const pathPart = rawPath
341
+ ? quoteIfHasSpaces(capturePath, platform)
342
+ : shellEscape(capturePath, platform);
343
+ const nodeNorm = nodeResolution.path.replace(/\\/g, '/');
344
+
345
+ // Windowless (Claude + Windows 1809): conhost.exe is the launched image and runs
346
+ // node directly, so it needs neither the cmd.exe `call` nor the PowerShell `&`
347
+ // wrapper. stdin is forwarded (verified). The node path is always double-quoted on
348
+ // Windows (it can contain spaces, e.g. C:/Program Files/nodejs/node.exe).
349
+ if (conhost) {
350
+ return `conhost.exe --headless "${nodeNorm.replace(/"/g, '""')}" ${pathPart}`;
351
+ }
352
+ if (isWin && rawPath && nodeNorm.includes(' ')) {
353
+ const quotedNode = `"${nodeNorm.replace(/"/g, '""')}"`;
354
+ // PowerShell (Cursor) handles a quoted first token natively — caller prepends `& `.
355
+ if (isPS) return `${quotedNode} ${pathPart}`;
356
+ // cmd.exe (Claude Code) needs `call` to run a quoted first token.
357
+ return `call ${quotedNode} ${pathPart}`;
358
+ }
359
+ return `${shellEscape(nodeNorm, platform)} ${pathPart}`;
416
360
  }
417
361
 
418
362
  /**
@@ -453,37 +397,20 @@ export function listClientFiles(sourceDir = join(__dirname, '..', 'client')) {
453
397
  }
454
398
 
455
399
  /**
456
- * Copy client/*.js to ~/.ai-lens/client/ and (optionally) write the launcher.
457
- *
458
- * Default behaviour matches a real install: writes the launcher so the hook
459
- * command target (`run.sh` / `run.cmd`) is always present. Tests that don't
460
- * want the launcher in their tmpdir can pass `writeLauncher: false`. If the
461
- * caller passes `writeLauncher: true` without a `nodeResolution`, we resolve
462
- * one lazily — same heuristic init.js uses — so a tests-style call like
463
- * `installClientFiles()` (no args) restores a working install.
400
+ * Copy client/*.js to ~/.ai-lens/client/ (default global install). No launcher is
401
+ * written any more (ADR 0003) — the hook command bakes the absolute node path itself,
402
+ * so installClientFiles no longer needs a node path. `writeLauncher` opt is accepted
403
+ * and ignored for back-compat with older callers/tests.
464
404
  *
465
405
  * @param {object} [opts]
466
406
  * @param {string} [opts.sourceDir]
467
407
  * @param {string} [opts.clientDir]
468
- * @param {{ path: string, stable: boolean }} [opts.nodeResolution] — auto-resolved if absent when writeLauncher=true
469
- * @param {string} [opts.platform]
470
- * @param {boolean} [opts.writeLauncher] — default true; pass false to skip launcher
471
408
  */
472
409
  export function installClientFiles(opts = {}) {
473
410
  const {
474
411
  sourceDir = join(__dirname, '..', 'client'),
475
412
  clientDir = CLIENT_INSTALL_DIR,
476
- platform = process.platform,
477
- writeLauncher: shouldWriteLauncher = true,
478
413
  } = opts;
479
- let { nodeResolution = null } = opts;
480
-
481
- if (shouldWriteLauncher && (!nodeResolution || !nodeResolution.path)) {
482
- nodeResolution = findStableNodePath();
483
- if (!nodeResolution || !nodeResolution.path) {
484
- throw new Error('installClientFiles: writeLauncher=true but no node binary could be resolved');
485
- }
486
- }
487
414
 
488
415
  mkdirSync(clientDir, { recursive: true });
489
416
 
@@ -504,18 +431,15 @@ export function installClientFiles(opts = {}) {
504
431
  join(clientDir, 'version.json'),
505
432
  JSON.stringify({ version, commit, packageRoot: PKG_ROOT }) + '\n',
506
433
  );
507
-
508
- if (shouldWriteLauncher) {
509
- writeLauncher({ clientDir, nodePath: nodeResolution.path, platform });
510
- }
511
434
  }
512
435
 
513
436
  /**
514
- * Remove installed client files from ~/.ai-lens/client/.
437
+ * Remove installed client files from the client dir (default ~/.ai-lens/client/).
438
+ * @param {string} [clientDir] — override for tests so they never rm the real install.
515
439
  */
516
- export function removeClientFiles() {
517
- if (existsSync(CLIENT_INSTALL_DIR)) {
518
- rmSync(CLIENT_INSTALL_DIR, { recursive: true, force: true });
440
+ export function removeClientFiles(clientDir = CLIENT_INSTALL_DIR) {
441
+ if (existsSync(clientDir)) {
442
+ rmSync(clientDir, { recursive: true, force: true });
519
443
  }
520
444
  }
521
445
 
@@ -535,6 +459,18 @@ const CLAUDE_HOOK_SPEC = {
535
459
  PreCompact: { matcher: '' },
536
460
  SubagentStart: { matcher: '' },
537
461
  SubagentStop: { matcher: '' },
462
+ // Observability events — captured + forwarded, not yet wired into analysis.
463
+ // All fire as Claude Code `command` hooks with an empty matcher (verified
464
+ // against live settings.json). Kept metric-neutral server-side via the
465
+ // exclusion sets in server/utils/event-types.js.
466
+ StopFailure: { matcher: '' },
467
+ Notification: { matcher: '' },
468
+ PermissionRequest: { matcher: '' },
469
+ PostCompact: { matcher: '' },
470
+ TaskCreated: { matcher: '' },
471
+ TaskCompleted: { matcher: '' },
472
+ InstructionsLoaded: { matcher: '' },
473
+ UserPromptExpansion: { matcher: '' },
538
474
  };
539
475
 
540
476
  const CURSOR_HOOK_NAMES = [
@@ -579,7 +515,7 @@ function memoizeCmd(produce) {
579
515
  * probes node candidates by itself.
580
516
  */
581
517
  export function makeClaudeHookDefs(ctx = null, _mode = 'global') {
582
- const getCmd = memoizeCmd(() => captureCommand({ useTilde: true, rawPath: true, ctx }));
518
+ const getCmd = memoizeCmd(() => captureCommand({ rawPath: true, windowless: true, ctx }));
583
519
  const defs = {};
584
520
  for (const [name, spec] of Object.entries(CLAUDE_HOOK_SPEC)) {
585
521
  defs[name] = () => ({ matcher: spec.matcher, hooks: [{ type: 'command', command: getCmd() }] });
@@ -627,7 +563,7 @@ export function makeCodexHookDefs(ctx = null, mode = 'global') {
627
563
  * @param {object} [ctx]
628
564
  */
629
565
  export function getClaudeCodeHookDefsWithPath(capturePath, ctx = null) {
630
- const getCmd = memoizeCmd(() => captureCommand({ rawPath: true, customPath: capturePath, ctx }));
566
+ const getCmd = memoizeCmd(() => captureCommand({ rawPath: true, customPath: capturePath, windowless: true, ctx }));
631
567
  const defs = {};
632
568
  for (const [name, spec] of Object.entries(CLAUDE_HOOK_SPEC)) {
633
569
  defs[name] = () => ({ matcher: spec.matcher, hooks: [{ type: 'command', command: getCmd() }] });
@@ -647,7 +583,7 @@ export function getClaudeCodeHookDefsWithPath(capturePath, ctx = null) {
647
583
  * @param {object} [ctx]
648
584
  */
649
585
  export function getClaudeCodeHookDefsWithProjectDir(relPath, ctx = null) {
650
- const getCmd = memoizeCmd(() => captureCommand({ projectDirRelPath: relPath, ctx }));
586
+ const getCmd = memoizeCmd(() => captureCommand({ projectDirRelPath: relPath, windowless: true, ctx }));
651
587
  const defs = {};
652
588
  for (const [name, spec] of Object.entries(CLAUDE_HOOK_SPEC)) {
653
589
  defs[name] = () => ({ matcher: spec.matcher, hooks: [{ type: 'command', command: getCmd() }] });
@@ -833,6 +769,14 @@ export function _parseHookCommand(cmd) {
833
769
  prefix = 'win-claude';
834
770
  rest = rest.slice(5).trim();
835
771
  }
772
+ // Windowless wrapper (Windows Claude, ADR 0003): conhost.exe --headless <node> <path>.
773
+ // Strip it like the other prefixes so the residue parses as the captureJs form and
774
+ // the existing absolute-node GUI-safe check applies unchanged.
775
+ const conhostMatch = rest.match(CONHOST_HEADLESS_PREFIX_RE);
776
+ if (conhostMatch) {
777
+ prefix = 'win-headless';
778
+ rest = rest.slice(conhostMatch[0].length).trim();
779
+ }
836
780
 
837
781
  const tokens = tokenizeHookCommand(rest);
838
782
  if (tokens.length === 0) {
@@ -892,18 +836,15 @@ function normalizePath(p) {
892
836
  }
893
837
 
894
838
  // A hook command is "GUI-safe" — i.e. survives a GUI app's minimal launchd PATH —
895
- // if it either routes through the per-machine launcher (run.sh / run.cmd, directly
896
- // OR via the transitional wrapper that execs it) OR runs capture.js with an
897
- // ABSOLUTE node path baked in. Bare `node` and `/usr/bin/env node` are NOT GUI-safe
898
- // (they depend on node being on PATH, which GUI Cursor/Claude often lack).
899
- //
900
- // Both GUI-safe forms capture events reliably, so init produces one of them and we
901
- // treat either as "current". Only the PATH-dependent forms get flagged outdated.
839
+ // if it runs capture.js with an ABSOLUTE node path baked in (optionally behind the
840
+ // conhost.exe --headless windowless wrapper, which _parseHookCommand strips). Bare
841
+ // `node` and `/usr/bin/env node` are NOT GUI-safe (they depend on node being on PATH,
842
+ // which GUI Cursor/Claude often lack). The old run.sh/run.cmd launcher is NO LONGER
843
+ // recognised here (ADR 0003 removed it) — it is therefore reported `outdated`, so
844
+ // `/setup` migrates an existing launcher install to the unified form. (`isAiLensCommand`
845
+ // still classifies run.* as ai-lens so `remove`/strip cleans up stale launchers.)
902
846
  export function isGuiSafeHookCommand(cmd) {
903
847
  if (!isAiLensCommand(cmd).isAiLens) return false;
904
- const n = (cmd || '').replace(/\\/g, '/');
905
- // Launcher: direct path, or the transitional wrapper that execs run.sh/run.cmd.
906
- if (n.includes('.ai-lens/client/run.sh') || n.includes('.ai-lens/client/run.cmd')) return true;
907
848
  // capture.js form: GUI-safe only when the node binary is an absolute path
908
849
  // (e.g. /opt/homebrew/bin/node), not bare `node` or an env shim.
909
850
  const p = _parseHookCommand(cmd);
@@ -945,10 +886,26 @@ function isAcceptableHookCommand(cmd) {
945
886
  export function isWrongPlatformProjectDirCommand(cmd, platform = process.platform) {
946
887
  if (!isClaudeProjectDirCommand(cmd)) return false;
947
888
  const n = (cmd || '').replace(/\\/g, '/');
889
+ // The conhost windowless form is unambiguously a Windows command (conhost.exe is
890
+ // Windows-only) and deliberately uses $CLAUDE_PROJECT_DIR — Claude Code substitutes
891
+ // that itself, so it expands on Windows too. It is therefore correct on win32 and
892
+ // wrong (won't run) anywhere else; the %VAR% rule below doesn't apply to it.
893
+ if (isConhostHeadlessCommand(n)) return platform !== 'win32';
948
894
  const correctVar = platform === 'win32' ? '%CLAUDE_PROJECT_DIR%' : '$CLAUDE_PROJECT_DIR';
949
895
  return !n.includes(correctVar);
950
896
  }
951
897
 
898
+ // Leading `conhost.exe --headless ` windowless wrapper, with the trailing space so a
899
+ // strip leaves the wrapped `<node> <path>` command. Single source of truth shared by
900
+ // _parseHookCommand and status.js (avoids the regex drifting between the two).
901
+ export const CONHOST_HEADLESS_PREFIX_RE = /^conhost(?:\.exe)?\s+--headless\s+/i;
902
+
903
+ // `conhost.exe --headless …` windowless wrapper (Windows). Tolerates an optional
904
+ // `.exe` and arbitrary spacing between the two tokens.
905
+ export function isConhostHeadlessCommand(cmd) {
906
+ return /(^|[\\/\s])conhost(\.exe)?\s+--headless\b/i.test(String(cmd || '').replace(/\\/g, '/'));
907
+ }
908
+
952
909
  // Whether a hook-config file is committed (tracked) in git. The anti-churn rule
953
910
  // (treat both $ and % CLAUDE_PROJECT_DIR forms as current) exists ONLY to keep a
954
911
  // COMMITTED cross-platform hook file from being flipped to one OS's syntax and
package/cli/init.js CHANGED
@@ -20,9 +20,8 @@ import {
20
20
  getClaudeCodeHookDefsWithPath, getClaudeCodeHookDefsWithProjectDir, getCursorHookDefsWithPath, getCodexHookDefsWithPath,
21
21
  cleanupLegacyHooks, cleanupOppositeScope, cleanupEmptyMcpJson, addCursorMcp, removeCursorMcp,
22
22
  checkHooksDisabled, enableHooks,
23
- findStableNodePath, isVersionPinnedNodePath, writeLauncher,
23
+ findStableNodePath,
24
24
  } from './hooks.js';
25
- import { mkdirSync } from 'node:fs';
26
25
  import { scanNestedClaudeProjects, summarizeNestedProjects } from './scan.js';
27
26
 
28
27
  function ask(question) {
@@ -360,7 +359,7 @@ function getInitArgs() {
360
359
  flags.useRepoPath = true;
361
360
  break;
362
361
  case '--install-launcher':
363
- flags.installLauncher = true;
362
+ // ADR 0003 removed the launcher; flag accepted but inert (back-compat).
364
363
  break;
365
364
  case '--import':
366
365
  flags.importHistory = true;
@@ -405,16 +404,14 @@ export default async function init() {
405
404
  // --project-hooks ALWAYS writes hooks regardless of whether ~/.cursor or
406
405
  // ~/.claude exist globally — so willWriteHooks must consider it.
407
406
  //
408
- // --install-launcher (added 0.8.69) forces launcher installation even when
409
- // --no-hooks. Used by bootstrap workflows like meta-cursor where static hook
410
- // templates point at ~/.ai-lens/client/run.sh but init must NOT overwrite
411
- // those templates with the dynamic hook form.
412
407
  const globalTools = detectInstalledTools();
413
408
  const willWriteHooks = !flags.noHooks && (globalTools.length > 0 || !!flags.projectHooks);
414
- const willInstallLauncher = willWriteHooks || !!flags.installLauncher;
415
409
  let nodeResolution = null;
416
410
  let ctx = null;
417
- if (willInstallLauncher) {
411
+ if (willWriteHooks) {
412
+ // The hook command bakes an absolute node path (ADR 0003 — no more launcher), so
413
+ // resolve a stable node binary up front. A version-pinned path still works but is
414
+ // warned about: it breaks on a node upgrade until the next /setup re-resolves it.
418
415
  nodeResolution = findStableNodePath();
419
416
  if (!nodeResolution) {
420
417
  error('Could not find any node binary on this system.');
@@ -429,9 +426,7 @@ export default async function init() {
429
426
  } else {
430
427
  detail(` Resolved node: ${nodeResolution.path} (${nodeResolution.source})`);
431
428
  }
432
- if (willWriteHooks) {
433
- ctx = { nodeResolution, platform: process.platform, clientDir: join(homedir(), '.ai-lens', 'client') };
434
- }
429
+ ctx = { nodeResolution, platform: process.platform, clientDir: join(homedir(), '.ai-lens', 'client') };
435
430
  }
436
431
 
437
432
  // Detect installed tools — re-detect with ctx now that it's available.
@@ -452,9 +447,17 @@ export default async function init() {
452
447
  info(' Cursor hooks will be written to this project (.cursor/hooks.json).');
453
448
  }
454
449
  if (claudeProject) {
450
+ // Б (HELP-1539): Claude Code project hooks go to the gitignored per-machine
451
+ // overlay (.claude/settings.local.json), NEVER the committed settings.json.
452
+ // A committed Claude Code hook carries one OS's form and is the recurring
453
+ // cross-OS bug source (flash on Windows / silently-never-fires on the other
454
+ // OS — HELP-1169, HELP-1539). The local layer lets each machine install its
455
+ // own OS-correct form (windowless conhost on Windows). Cursor/Codex keep their
456
+ // committed project files — they don't share the cross-OS-committed problem.
457
+ claudeProject.configPath = join(claudeProject.dirPath, 'settings.local.json');
455
458
  tools = tools.filter(t => t.name !== 'Claude Code');
456
459
  tools.push(claudeProject);
457
- info(' Claude Code hooks will be written to this project (.claude/settings.json).');
460
+ info(' Claude Code hooks will be written per-machine to this project (.claude/settings.local.json, gitignored).');
458
461
  }
459
462
  if (codexProject) {
460
463
  tools = tools.filter(t => t.name !== 'Codex');
@@ -601,34 +604,12 @@ export default async function init() {
601
604
  if (!flags.useRepoPath) {
602
605
  heading('Installing client files...');
603
606
  try {
604
- installClientFiles({
605
- nodeResolution,
606
- platform: process.platform,
607
- writeLauncher: willWriteHooks,
608
- });
607
+ installClientFiles({ platform: process.platform });
609
608
  success(' Copied client files to ~/.ai-lens/client/');
610
- if (willWriteHooks) {
611
- detail(' Wrote per-machine launcher (run.sh / run.cmd).');
612
- }
613
609
  } catch (err) {
614
610
  error(` Failed to install client files: ${err.message}`);
615
611
  return;
616
612
  }
617
- } else if (willInstallLauncher) {
618
- // --use-repo-path with --install-launcher: keep capture.js out of the install
619
- // dir (it lives in the monorepo, auto-updates via git pull) but still create
620
- // the per-machine launcher so static hooks can route through it for proper
621
- // node resolution. The meta-cursor bootstrap is the primary consumer.
622
- heading('Installing launcher (--install-launcher) ...');
623
- try {
624
- const clientDir = join(homedir(), '.ai-lens', 'client');
625
- mkdirSync(clientDir, { recursive: true });
626
- writeLauncher({ clientDir, nodePath: nodeResolution.path, platform: process.platform });
627
- success(' Wrote per-machine launcher to ~/.ai-lens/client/');
628
- } catch (err) {
629
- error(` Failed to write launcher: ${err.message}`);
630
- return;
631
- }
632
613
  } else {
633
614
  detail(' Skipping client install (--use-repo-path: using package copy).');
634
615
  }
@@ -994,9 +975,11 @@ export default async function init() {
994
975
  // the hook silently never fires (cmd.exe vs sh; upstream anthropic/claude-code#24710
995
976
  // closed as not planned). When the committed form doesn't match this OS, mirror the
996
977
  // hooks into .claude/settings.local.json — Claude Code's per-machine layer
997
- // (auto-gitignored, merged with settings.json at runtime). Runs regardless of
998
- // --project-hooks: the committed file arrives with the clone, not via init.
999
- if (!flags.noHooks) {
978
+ // (auto-gitignored, merged with settings.json at runtime). Only for the
979
+ // clone-with-committed-hooks case: under --project-hooks we already wrote the
980
+ // OS-correct form straight into settings.local.json above, so mirroring the
981
+ // committed file on top would be redundant (and racy against that write).
982
+ if (!flags.noHooks && !flags.projectHooks) {
1000
983
  try {
1001
984
  const overlayProjectRoot = resolve(process.cwd());
1002
985
  const overlayClaudeTool = getClaudeCodeToolConfig(overlayProjectRoot, 'Claude Code (project)', ctx);
package/cli/scan.js CHANGED
@@ -101,16 +101,14 @@ function classifyProject(projectDir) {
101
101
  };
102
102
  }
103
103
 
104
- if (hasAnyNonAiLensHooks(settings)) {
105
- return {
106
- status: 'has non-ai-lens hooks',
107
- installTarget: 'settings.json',
108
- existingConfig: settings,
109
- };
110
- }
111
-
104
+ // AI Lens Claude hooks are per-machine / OS-specific (ADR 0003 — absolute node path,
105
+ // conhost.exe on Windows). They must NEVER be written into a COMMITTED settings.json
106
+ // (that re-introduces the cross-OS / cross-machine breakage MR !298 removed). Always
107
+ // install into the gitignored, per-machine settings.local.json, which Claude Code
108
+ // merges with settings.json at runtime — so a project's own non-ai-lens hooks in
109
+ // settings.json keep working alongside.
112
110
  return {
113
- status: 'missing',
111
+ status: hasAnyNonAiLensHooks(settings) ? 'has non-ai-lens hooks' : 'missing',
114
112
  installTarget: 'settings.local.json',
115
113
  existingConfig: settingsLocal,
116
114
  };
package/cli/status.js CHANGED
@@ -7,7 +7,7 @@ import tls from 'node:tls';
7
7
 
8
8
  import { TLS_TRUST_CODES, tlsCodeOf, tlsVerdictSummary, issuerName } from '../client/tls-trust.js';
9
9
 
10
- import { getVersionInfo, readLensConfig, detectInstalledTools, getCursorToolConfig, getClaudeCodeToolConfig, getCodexToolConfig, analyzeToolHooks, checkHooksDisabled, verifyCodexHookTrust, CAPTURE_PATH, TOOL_CONFIGS, isClaudeProjectDirCommand, analyzeClaudeLocalOverlay, extractProjectDirRelPath, globalClaudeHooksActive } from './hooks.js';
10
+ import { getVersionInfo, readLensConfig, detectInstalledTools, getCursorToolConfig, getClaudeCodeToolConfig, getCodexToolConfig, analyzeToolHooks, checkHooksDisabled, verifyCodexHookTrust, CAPTURE_PATH, TOOL_CONFIGS, isClaudeProjectDirCommand, analyzeClaudeLocalOverlay, extractProjectDirRelPath, globalClaudeHooksActive, CONHOST_HEADLESS_PREFIX_RE } from './hooks.js';
11
11
  import { DATA_DIR, PENDING_DIR, SENDING_DIR, SESSION_PATHS_DIR, LOG_PATH, CAPTURE_LOG_PATH, LAST_STATUS_REPORT_PATH, getGitIdentity, getMonitoredProjects } from '../client/config.js';
12
12
  import { isLockStale } from '../client/sender.js';
13
13
  import { initLogger, info, success, warn, error, heading, blank } from './logger.js';
@@ -167,13 +167,20 @@ function validateHookCommandPaths(tool) {
167
167
  issues.push(`capture.js not found at: ${capturePath}`);
168
168
  }
169
169
  }
170
- const cmdForNode = command.replace(/^& /, '');
170
+ // Strip the Cursor PowerShell `& ` and the Windows windowless `conhost.exe
171
+ // --headless` wrappers so the FIRST remaining token is the real node binary —
172
+ // then flag a baked absolute node path that no longer exists (e.g. after a node
173
+ // upgrade moved a version-pinned path), so it surfaces instead of silently
174
+ // dropping events (ADR 0003 §6 / R3).
175
+ const cmdForNode = command
176
+ .replace(/^& /, '')
177
+ .replace(CONHOST_HEADLESS_PREFIX_RE, '');
171
178
  if (!cmdForNode.startsWith('/usr/bin/env node')) {
172
179
  const nodeMatch = cmdForNode.match(/^["']([^"']+)["']|^(\S+)/);
173
180
  if (nodeMatch) {
174
181
  const nodePath = expandTilde(nodeMatch[1] || nodeMatch[2]);
175
182
  if (nodePath !== 'node' && !existsSync(nodePath)) {
176
- issues.push(`node not found at: ${nodePath}`);
183
+ issues.push(`node not found at: ${nodePath} — re-run \`ai-lens init\` to re-resolve node`);
177
184
  }
178
185
  }
179
186
  }
package/client/capture.js CHANGED
@@ -99,6 +99,9 @@ export const TRUNCATION_LIMITS = {
99
99
  userPrompt: 1000,
100
100
  agentResponse: 1000,
101
101
  agentThought: 500,
102
+ // Compact summaries / task descriptions can run multiple KB — keep a
103
+ // generous prefix in `data`; the full text always survives in `raw`.
104
+ summary: 2000,
102
105
  };
103
106
 
104
107
  export function truncate(text, maxLen) {
@@ -684,6 +687,16 @@ const CLAUDE_CODE_TYPE_MAP = {
684
687
  PreCompact: 'PreCompact',
685
688
  SubagentStart: 'SubagentStart',
686
689
  SubagentStop: 'SubagentStop',
690
+ // Observability events — captured + forwarded, not yet wired into analysis.
691
+ // Kept metric-neutral server-side (server/utils/event-types.js exclusion sets).
692
+ StopFailure: 'StopFailure',
693
+ Notification: 'Notification',
694
+ PermissionRequest: 'PermissionRequest',
695
+ PostCompact: 'PostCompact',
696
+ TaskCreated: 'TaskCreated',
697
+ TaskCompleted: 'TaskCompleted',
698
+ InstructionsLoaded: 'InstructionsLoaded',
699
+ UserPromptExpansion: 'UserPromptExpansion',
687
700
  };
688
701
 
689
702
  // Tools that indicate plan mode transitions
@@ -795,6 +808,62 @@ function normalizeClaudeCode(event) {
795
808
  agent_type: event.agent_type || null,
796
809
  };
797
810
  break;
811
+ // --- Observability events (captured + forwarded, not yet analyzed) ------
812
+ case 'StopFailure':
813
+ // A turn that aborted (session limit / prompt-too-long / 5xx overload /
814
+ // rate limit). NOTE: deliberately does NOT read the transcript delta for
815
+ // TokenUsage (unlike Stop/SubagentStop below) — those tokens are picked
816
+ // up by the next Stop in the session via the persisted offset.
817
+ data = {
818
+ error: event.error ?? null,
819
+ last_assistant_message: truncate(event.last_assistant_message || '', TRUNCATION_LIMITS.userPrompt),
820
+ };
821
+ break;
822
+ case 'Notification':
823
+ data = {
824
+ notification_type: event.notification_type ?? null,
825
+ message: truncate(event.message || '', TRUNCATION_LIMITS.userPrompt),
826
+ };
827
+ break;
828
+ case 'PermissionRequest': {
829
+ const reqTool = event.tool_name || event.tool || 'unknown';
830
+ data = {
831
+ tool: reqTool,
832
+ input: truncateToolInput(event.tool_input || event.input, reqTool),
833
+ permission_mode: event.permission_mode ?? null,
834
+ };
835
+ break;
836
+ }
837
+ case 'PostCompact':
838
+ data = {
839
+ trigger: event.trigger ?? null,
840
+ compact_summary: truncate(event.compact_summary || '', TRUNCATION_LIMITS.summary),
841
+ };
842
+ break;
843
+ case 'TaskCreated':
844
+ case 'TaskCompleted':
845
+ data = {
846
+ task_id: event.task_id ?? null,
847
+ task_subject: truncate(event.task_subject || '', TRUNCATION_LIMITS.userPrompt),
848
+ task_description: truncate(event.task_description || '', TRUNCATION_LIMITS.summary),
849
+ };
850
+ break;
851
+ case 'InstructionsLoaded':
852
+ data = {
853
+ file_path: event.file_path ?? null,
854
+ memory_type: event.memory_type ?? null,
855
+ load_reason: event.load_reason ?? null,
856
+ trigger_file_path: event.trigger_file_path ?? null,
857
+ };
858
+ break;
859
+ case 'UserPromptExpansion':
860
+ data = {
861
+ expansion_type: event.expansion_type ?? null,
862
+ command_name: event.command_name ?? null,
863
+ command_source: event.command_source ?? null,
864
+ prompt: truncate(event.prompt || '', TRUNCATION_LIMITS.userPrompt),
865
+ };
866
+ break;
798
867
  default:
799
868
  data = { hook: hookType };
800
869
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-lens",
3
- "version": "0.8.92",
3
+ "version": "0.8.94",
4
4
  "type": "module",
5
5
  "description": "Centralized session analytics for AI coding tools",
6
6
  "bin": {