ai-lens 0.8.93 → 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
- 255d9a3
1
+ 1b043cc
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.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
+
5
8
  ## 0.8.93 — 2026-06-15
6
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
7
10
 
package/cli/hooks.js CHANGED
@@ -230,73 +230,10 @@ export function findStableNodePath({
230
230
  return { path: execPath, stable: false, source: 'execPathFallback' };
231
231
  }
232
232
 
233
- // ---------------------------------------------------------------------------
234
- // Per-machine launcher (run.sh / run.cmd)
235
- // ---------------------------------------------------------------------------
236
-
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');
269
-
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;
299
- }
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.
300
237
 
301
238
  // ---------------------------------------------------------------------------
302
239
  // Hook command construction
@@ -349,7 +286,8 @@ export function captureCommand(opts = {}) {
349
286
  if (typeof opts === 'boolean') {
350
287
  opts = { useTilde: opts, rawPath: arguments[1], customPath: arguments[2] ?? null };
351
288
  }
352
- 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
353
291
  const ctx = opts.ctx ?? resolveDefaultCtx();
354
292
  // Tolerate partial ctx (e.g. only nodeResolution supplied) by filling in module
355
293
  // defaults — keeps callers from having to repeat platform/clientDir everywhere.
@@ -357,9 +295,11 @@ export function captureCommand(opts = {}) {
357
295
  const clientDir = ctx.clientDir ?? CLIENT_INSTALL_DIR;
358
296
  const nodeResolution = ctx.nodeResolution;
359
297
  const isWin = platform === 'win32';
360
- // Windows-only windowless wrapper (see supportsConhostHeadless). Resolved per-ctx
361
- // so a build < 1809 falls back to the bare form. Never applied off Windows.
362
- const conhost = isWin && (ctx.conhost ?? supportsConhostHeadless());
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());
363
303
  // shell hint distinguishes cmd.exe (Claude Code) from PowerShell (Cursor) on
364
304
  // Windows — the two need different escaping for paths with spaces.
365
305
  const isPS = shell === 'powershell';
@@ -385,61 +325,38 @@ export function captureCommand(opts = {}) {
385
325
  return `node "${dir}/${rel}"`;
386
326
  }
387
327
 
388
- // --use-repo-path: legacy <node> <capture.js> form.
389
- if (customPath != null) {
390
- if (!nodeResolution || !nodeResolution.path) {
391
- throw new Error('captureCommand: nodeResolution is required for --use-repo-path');
392
- }
393
- const capturePath = customPath.replace(/\\/g, '/');
394
- // Even in rawPath mode (Claude Code "do not unconditionally wrap"), the script
395
- // path MUST be quoted when it contains spaces — otherwise /bin/sh, cmd.exe and
396
- // PowerShell all split it into separate argv tokens (e.g. `/Users/foo bar/...`).
397
- // Use shell-appropriate quoting: double-quote on Windows (works for both
398
- // cmd.exe and PowerShell), POSIX single-quote elsewhere.
399
- const pathPart = rawPath
400
- ? quoteIfHasSpaces(capturePath, platform)
401
- : shellEscape(capturePath, platform);
402
- const nodeNorm = nodeResolution.path.replace(/\\/g, '/');
403
- if (isWin && rawPath && nodeNorm.includes(' ')) {
404
- const quotedNode = `"${nodeNorm.replace(/"/g, '""')}"`;
405
- // PowerShell (Cursor) handles a quoted first token natively caller will
406
- // prepend `& `, giving `& "<node>" <path>`.
407
- if (isPS) return `${quotedNode} ${pathPart}`;
408
- // cmd.exe (Claude Code) doesn't always treat a quoted first token as a
409
- // command — `call` is the canonical workaround. Avoids the previous bare-
410
- // `node` fallback that depended on PATH (C:\Program Files\nodejs\node.exe
411
- // wouldn't be reachable from launchd-like environments).
412
- return `call ${quotedNode} ${pathPart}`;
413
- }
414
- return `${shellEscape(nodeNorm, platform)} ${pathPart}`;
415
- }
416
-
417
- // Launcher form. Path inside the command — tilde for project-hooks portability on POSIX;
418
- // absolute on Windows (PowerShell/cmd don't expand ~).
419
- const filename = launcherFilename(platform);
420
- let launcherPath;
421
- if (useTilde && !isWin) {
422
- launcherPath = `~/.ai-lens/client/${filename}`;
423
- } else {
424
- launcherPath = join(clientDir, filename).replace(/\\/g, '/');
425
- }
426
-
427
- // POSIX: quote the launcher path unless it's the unquoted tilde form (which must
428
- // remain unquoted for shell tilde expansion). rawPath (Claude Code) leaves the
429
- // tilde form bare too — Claude Code passes commands to /bin/sh which expands ~.
430
- if (!isWin) {
431
- if (useTilde) return launcherPath; // unquoted ~/path
432
- return shellEscape(launcherPath, 'linux');
433
- }
434
-
435
- // Windows: cmd.exe (Claude Code, rawPath=true) needs `call "..."` when the
436
- // launcher path contains spaces; otherwise plain quoted path works.
437
- // PowerShell (Cursor) handles `"..."` via the & prefix (added by cursorCaptureCommand).
438
- const quoted = `"${launcherPath.replace(/"/g, '""')}"`;
439
- if (rawPath && launcherPath.includes(' ') && !isPS) {
440
- return `call ${quoted}`;
441
- }
442
- 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}`;
443
360
  }
444
361
 
445
362
  /**
@@ -480,37 +397,20 @@ export function listClientFiles(sourceDir = join(__dirname, '..', 'client')) {
480
397
  }
481
398
 
482
399
  /**
483
- * Copy client/*.js to ~/.ai-lens/client/ and (optionally) write the launcher.
484
- *
485
- * Default behaviour matches a real install: writes the launcher so the hook
486
- * command target (`run.sh` / `run.cmd`) is always present. Tests that don't
487
- * want the launcher in their tmpdir can pass `writeLauncher: false`. If the
488
- * caller passes `writeLauncher: true` without a `nodeResolution`, we resolve
489
- * one lazily — same heuristic init.js uses — so a tests-style call like
490
- * `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.
491
404
  *
492
405
  * @param {object} [opts]
493
406
  * @param {string} [opts.sourceDir]
494
407
  * @param {string} [opts.clientDir]
495
- * @param {{ path: string, stable: boolean }} [opts.nodeResolution] — auto-resolved if absent when writeLauncher=true
496
- * @param {string} [opts.platform]
497
- * @param {boolean} [opts.writeLauncher] — default true; pass false to skip launcher
498
408
  */
499
409
  export function installClientFiles(opts = {}) {
500
410
  const {
501
411
  sourceDir = join(__dirname, '..', 'client'),
502
412
  clientDir = CLIENT_INSTALL_DIR,
503
- platform = process.platform,
504
- writeLauncher: shouldWriteLauncher = true,
505
413
  } = opts;
506
- let { nodeResolution = null } = opts;
507
-
508
- if (shouldWriteLauncher && (!nodeResolution || !nodeResolution.path)) {
509
- nodeResolution = findStableNodePath();
510
- if (!nodeResolution || !nodeResolution.path) {
511
- throw new Error('installClientFiles: writeLauncher=true but no node binary could be resolved');
512
- }
513
- }
514
414
 
515
415
  mkdirSync(clientDir, { recursive: true });
516
416
 
@@ -531,18 +431,15 @@ export function installClientFiles(opts = {}) {
531
431
  join(clientDir, 'version.json'),
532
432
  JSON.stringify({ version, commit, packageRoot: PKG_ROOT }) + '\n',
533
433
  );
534
-
535
- if (shouldWriteLauncher) {
536
- writeLauncher({ clientDir, nodePath: nodeResolution.path, platform });
537
- }
538
434
  }
539
435
 
540
436
  /**
541
- * 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.
542
439
  */
543
- export function removeClientFiles() {
544
- if (existsSync(CLIENT_INSTALL_DIR)) {
545
- 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 });
546
443
  }
547
444
  }
548
445
 
@@ -562,6 +459,18 @@ const CLAUDE_HOOK_SPEC = {
562
459
  PreCompact: { matcher: '' },
563
460
  SubagentStart: { matcher: '' },
564
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: '' },
565
474
  };
566
475
 
567
476
  const CURSOR_HOOK_NAMES = [
@@ -606,7 +515,7 @@ function memoizeCmd(produce) {
606
515
  * probes node candidates by itself.
607
516
  */
608
517
  export function makeClaudeHookDefs(ctx = null, _mode = 'global') {
609
- const getCmd = memoizeCmd(() => captureCommand({ useTilde: true, rawPath: true, ctx }));
518
+ const getCmd = memoizeCmd(() => captureCommand({ rawPath: true, windowless: true, ctx }));
610
519
  const defs = {};
611
520
  for (const [name, spec] of Object.entries(CLAUDE_HOOK_SPEC)) {
612
521
  defs[name] = () => ({ matcher: spec.matcher, hooks: [{ type: 'command', command: getCmd() }] });
@@ -654,7 +563,7 @@ export function makeCodexHookDefs(ctx = null, mode = 'global') {
654
563
  * @param {object} [ctx]
655
564
  */
656
565
  export function getClaudeCodeHookDefsWithPath(capturePath, ctx = null) {
657
- const getCmd = memoizeCmd(() => captureCommand({ rawPath: true, customPath: capturePath, ctx }));
566
+ const getCmd = memoizeCmd(() => captureCommand({ rawPath: true, customPath: capturePath, windowless: true, ctx }));
658
567
  const defs = {};
659
568
  for (const [name, spec] of Object.entries(CLAUDE_HOOK_SPEC)) {
660
569
  defs[name] = () => ({ matcher: spec.matcher, hooks: [{ type: 'command', command: getCmd() }] });
@@ -674,7 +583,7 @@ export function getClaudeCodeHookDefsWithPath(capturePath, ctx = null) {
674
583
  * @param {object} [ctx]
675
584
  */
676
585
  export function getClaudeCodeHookDefsWithProjectDir(relPath, ctx = null) {
677
- const getCmd = memoizeCmd(() => captureCommand({ projectDirRelPath: relPath, ctx }));
586
+ const getCmd = memoizeCmd(() => captureCommand({ projectDirRelPath: relPath, windowless: true, ctx }));
678
587
  const defs = {};
679
588
  for (const [name, spec] of Object.entries(CLAUDE_HOOK_SPEC)) {
680
589
  defs[name] = () => ({ matcher: spec.matcher, hooks: [{ type: 'command', command: getCmd() }] });
@@ -860,6 +769,14 @@ export function _parseHookCommand(cmd) {
860
769
  prefix = 'win-claude';
861
770
  rest = rest.slice(5).trim();
862
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
+ }
863
780
 
864
781
  const tokens = tokenizeHookCommand(rest);
865
782
  if (tokens.length === 0) {
@@ -919,18 +836,15 @@ function normalizePath(p) {
919
836
  }
920
837
 
921
838
  // A hook command is "GUI-safe" — i.e. survives a GUI app's minimal launchd PATH —
922
- // if it either routes through the per-machine launcher (run.sh / run.cmd, directly
923
- // OR via the transitional wrapper that execs it) OR runs capture.js with an
924
- // ABSOLUTE node path baked in. Bare `node` and `/usr/bin/env node` are NOT GUI-safe
925
- // (they depend on node being on PATH, which GUI Cursor/Claude often lack).
926
- //
927
- // Both GUI-safe forms capture events reliably, so init produces one of them and we
928
- // 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.)
929
846
  export function isGuiSafeHookCommand(cmd) {
930
847
  if (!isAiLensCommand(cmd).isAiLens) return false;
931
- const n = (cmd || '').replace(/\\/g, '/');
932
- // Launcher: direct path, or the transitional wrapper that execs run.sh/run.cmd.
933
- if (n.includes('.ai-lens/client/run.sh') || n.includes('.ai-lens/client/run.cmd')) return true;
934
848
  // capture.js form: GUI-safe only when the node binary is an absolute path
935
849
  // (e.g. /opt/homebrew/bin/node), not bare `node` or an env shim.
936
850
  const p = _parseHookCommand(cmd);
@@ -981,6 +895,11 @@ export function isWrongPlatformProjectDirCommand(cmd, platform = process.platfor
981
895
  return !n.includes(correctVar);
982
896
  }
983
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
+
984
903
  // `conhost.exe --headless …` windowless wrapper (Windows). Tolerates an optional
985
904
  // `.exe` and arbitrary spacing between the two tokens.
986
905
  export function isConhostHeadlessCommand(cmd) {
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.93",
3
+ "version": "0.8.94",
4
4
  "type": "module",
5
5
  "description": "Centralized session analytics for AI coding tools",
6
6
  "bin": {