ai-lens 0.8.68 → 0.8.70

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
- 72eb1f0
1
+ 0912a07
package/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
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.70 — 2026-05-28
6
+ - diag: `sender-spawn-failed`, `codex-watcher-spawn-failed` and `queue-write-failed` entries in the capture log now record the OS `error.code` (e.g. EMFILE, EACCES). `ai-lens status` surfaces a per-category code breakdown so these failures can be diagnosed centrally instead of guessing from a bare count. The raw error message (which may contain local paths) stays on your machine — only the short code travels in the status report.
7
+
8
+ ## 0.8.69 — 2026-05-27
9
+ - feat: per-machine launcher (`~/.ai-lens/client/run.sh` / `run.cmd`) now also accepts an optional script path as its first argument. With no args it still execs the sibling `capture.js` (default install), but `~/.ai-lens/client/run.sh path/to/some/capture.js` execs that script with the launcher's resolved node — letting bootstrap-style workflows (e.g. meta-cursor) route through the launcher for proper node resolution while keeping `capture.js` under git in the workspace repo.
10
+ - feat: new `--install-launcher` flag for `init`. Forces launcher installation even when `--no-hooks` is set, so the meta-cursor setup skill can wire up `~/.ai-lens/client/run.sh` without touching the static hook templates in the workspace repo.
11
+
5
12
  ## 0.8.68 — 2026-05-27
6
13
  - fix: hooks now resolve node from nvm, asdf, fnm, volta and n in addition to Homebrew. Previously `init` fell back to `/usr/bin/env node`, which made GUI Cursor on macOS silently lose every event because launchd's minimal PATH doesn't include node. Affected users with custom node installs (e.g. `/Users/<you>/node/bin/node`) saw hundreds of dropped events with no visible error.
7
14
  - feat: `init` now installs a per-machine launcher (`~/.ai-lens/client/run.sh` on macOS/Linux, `run.cmd` on Windows) with the resolved node path baked in. Hook commands invoke the launcher directly, so they no longer depend on the GUI app's PATH and survive `brew upgrade node`.
package/cli/hooks.js CHANGED
@@ -242,8 +242,20 @@ export function launcherFilename(platform = process.platform) {
242
242
 
243
243
  /**
244
244
  * Write a per-machine launcher script with the resolved node path baked in.
245
- * The launcher uses dirname-of-script to find capture.js, so it's independent
246
- * of HOME / USERPROFILE / cwd at execution time.
245
+ *
246
+ * The launcher acts as a node-resolution shim that supports two invocation modes:
247
+ *
248
+ * 1. No arguments — run the sibling capture.js (the default install flow where
249
+ * both the launcher and capture.js live in ~/.ai-lens/client/).
250
+ * 2. Script path as $1 — run that script with the resolved node. This is the
251
+ * repo-path mode (e.g. meta-cursor static hooks pointing at
252
+ * `internal/analytics/ai-lens/client/capture.js`) where capture.js auto-
253
+ * updates via `git pull` and only the node binary needs per-machine baking.
254
+ *
255
+ * In both cases the launcher is independent of HOME / USERPROFILE / cwd — for
256
+ * mode 1 the path is derived from `dirname $0`; for mode 2 it's whatever the
257
+ * caller passes through (typically a workspace-relative path that resolves
258
+ * against the cwd Cursor/Claude Code set when firing the hook).
247
259
  *
248
260
  * @param {object} opts
249
261
  * @param {string} opts.clientDir — directory where the launcher (and capture.js) live
@@ -256,7 +268,13 @@ export function writeLauncher({ clientDir = CLIENT_INSTALL_DIR, nodePath, platfo
256
268
 
257
269
  if (platform === 'win32') {
258
270
  const escaped = nodePath.replace(/"/g, '""');
259
- const content = `@echo off\r\n"${escaped}" "%~dp0capture.js" %*\r\n`;
271
+ const content =
272
+ '@echo off\r\n'
273
+ + 'if "%~1"=="" goto default\r\n'
274
+ + `"${escaped}" %*\r\n`
275
+ + 'goto :eof\r\n'
276
+ + ':default\r\n'
277
+ + `"${escaped}" "%~dp0capture.js"\r\n`;
260
278
  const target = join(clientDir, 'run.cmd');
261
279
  writeFileSync(target, content);
262
280
  return target;
@@ -265,8 +283,14 @@ export function writeLauncher({ clientDir = CLIENT_INSTALL_DIR, nodePath, platfo
265
283
  const escaped = shellEscape(nodePath, 'linux'); // POSIX single-quote escape
266
284
  const content =
267
285
  '#!/bin/sh\n'
286
+ + `# AI Lens per-machine launcher. Two modes:\n`
287
+ + `# $0 → exec node on sibling capture.js (default install)\n`
288
+ + `# $0 <script> → exec node on <script> with remaining args (repo-path mode)\n`
289
+ + 'if [ $# -gt 0 ]; then\n'
290
+ + ` exec ${escaped} "$@"\n`
291
+ + 'fi\n'
268
292
  + 'DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)\n'
269
- + `exec ${escaped} "$DIR/capture.js" "$@"\n`;
293
+ + `exec ${escaped} "$DIR/capture.js"\n`;
270
294
  const target = join(clientDir, 'run.sh');
271
295
  writeFileSync(target, content);
272
296
  try { chmodSync(target, 0o755); } catch { /* best effort */ }
package/cli/init.js CHANGED
@@ -20,8 +20,9 @@ import {
20
20
  getClaudeCodeHookDefsWithPath, getCursorHookDefsWithPath,
21
21
  cleanupLegacyHooks, cleanupOppositeScope, cleanupEmptyMcpJson, addCursorMcp, removeCursorMcp,
22
22
  checkHooksDisabled, enableHooks,
23
- findStableNodePath, isVersionPinnedNodePath,
23
+ findStableNodePath, isVersionPinnedNodePath, writeLauncher,
24
24
  } from './hooks.js';
25
+ import { mkdirSync } from 'node:fs';
25
26
  import { scanNestedClaudeProjects, summarizeNestedProjects } from './scan.js';
26
27
 
27
28
  function ask(question) {
@@ -351,6 +352,9 @@ function getInitArgs() {
351
352
  case '--use-repo-path':
352
353
  flags.useRepoPath = true;
353
354
  break;
355
+ case '--install-launcher':
356
+ flags.installLauncher = true;
357
+ break;
354
358
  case '--mcp-scope':
355
359
  if (i + 1 < args.length) flags.mcpScope = args[++i];
356
360
  else process.stderr.write('Warning: --mcp-scope requires a value\n');
@@ -359,7 +363,7 @@ function getInitArgs() {
359
363
  const a = args[i];
360
364
  if (a.startsWith('-')) {
361
365
  process.stderr.write(`Warning: unknown flag "${a}" — did you mean --${a.replace(/^-+/, '')}?\n`);
362
- } else if (['server', 'projects', 'yes', 'no-mcp', 'no-hooks', 'project-hooks', 'use-repo-path', 'mcp-scope'].includes(a)) {
366
+ } else if (['server', 'projects', 'yes', 'no-mcp', 'no-hooks', 'project-hooks', 'use-repo-path', 'install-launcher', 'mcp-scope'].includes(a)) {
363
367
  process.stderr.write(`Warning: unexpected argument "${a}" — did you mean --${a}?\n`);
364
368
  }
365
369
  }
@@ -387,11 +391,17 @@ export default async function init() {
387
391
  //
388
392
  // --project-hooks ALWAYS writes hooks regardless of whether ~/.cursor or
389
393
  // ~/.claude exist globally — so willWriteHooks must consider it.
394
+ //
395
+ // --install-launcher (added 0.8.69) forces launcher installation even when
396
+ // --no-hooks. Used by bootstrap workflows like meta-cursor where static hook
397
+ // templates point at ~/.ai-lens/client/run.sh but init must NOT overwrite
398
+ // those templates with the dynamic hook form.
390
399
  const globalTools = detectInstalledTools();
391
400
  const willWriteHooks = !flags.noHooks && (globalTools.length > 0 || !!flags.projectHooks);
401
+ const willInstallLauncher = willWriteHooks || !!flags.installLauncher;
392
402
  let nodeResolution = null;
393
403
  let ctx = null;
394
- if (willWriteHooks) {
404
+ if (willInstallLauncher) {
395
405
  nodeResolution = findStableNodePath();
396
406
  if (!nodeResolution) {
397
407
  error('Could not find any node binary on this system.');
@@ -406,7 +416,9 @@ export default async function init() {
406
416
  } else {
407
417
  detail(` Resolved node: ${nodeResolution.path} (${nodeResolution.source})`);
408
418
  }
409
- ctx = { nodeResolution, platform: process.platform, clientDir: join(homedir(), '.ai-lens', 'client') };
419
+ if (willWriteHooks) {
420
+ ctx = { nodeResolution, platform: process.platform, clientDir: join(homedir(), '.ai-lens', 'client') };
421
+ }
410
422
  }
411
423
 
412
424
  // Detect installed tools — re-detect with ctx now that it's available.
@@ -545,9 +557,8 @@ export default async function init() {
545
557
  // Build new config in memory — saved after "Proceed?" confirmation
546
558
  const newConfig = { ...currentConfig, serverUrl, projects };
547
559
 
548
- // Install client files to ~/.ai-lens/client/ (skip when --use-repo-path).
549
- // Launcher (run.sh / run.cmd) is written only when we will actually wire up hooks
550
- // — --no-hooks installs leave it out (and don't require a resolved node path).
560
+ // Install client files to ~/.ai-lens/client/ (skip when --use-repo-path because
561
+ // capture.js comes from the monorepo / package source in that mode).
551
562
  if (!flags.useRepoPath) {
552
563
  heading('Installing client files...');
553
564
  try {
@@ -564,6 +575,21 @@ export default async function init() {
564
575
  error(` Failed to install client files: ${err.message}`);
565
576
  return;
566
577
  }
578
+ } else if (willInstallLauncher) {
579
+ // --use-repo-path with --install-launcher: keep capture.js out of the install
580
+ // dir (it lives in the monorepo, auto-updates via git pull) but still create
581
+ // the per-machine launcher so static hooks can route through it for proper
582
+ // node resolution. The meta-cursor bootstrap is the primary consumer.
583
+ heading('Installing launcher (--install-launcher) ...');
584
+ try {
585
+ const clientDir = join(homedir(), '.ai-lens', 'client');
586
+ mkdirSync(clientDir, { recursive: true });
587
+ writeLauncher({ clientDir, nodePath: nodeResolution.path, platform: process.platform });
588
+ success(' Wrote per-machine launcher to ~/.ai-lens/client/');
589
+ } catch (err) {
590
+ error(` Failed to write launcher: ${err.message}`);
591
+ return;
592
+ }
567
593
  } else {
568
594
  detail(' Skipping client install (--use-repo-path: using package copy).');
569
595
  }
package/cli/status.js CHANGED
@@ -725,8 +725,13 @@ function checkCaptureLog() {
725
725
  return { ok: false, summary: `error reading log: ${err.message}`, detail: `Error: ${err.message}` };
726
726
  }
727
727
 
728
- // Count entries by category (reason for drops, msg for errors)
728
+ // Count entries by category (reason for drops, msg for errors). For error
729
+ // entries that carry an `error.code` (spawn/queue failures), also tally the
730
+ // code distribution per category so it reaches the server via client_reports
731
+ // — the count alone can't tell EMFILE from EACCES from ENOENT. The raw
732
+ // error.message strings stay local (may contain paths); only the code travels.
729
733
  const counts = {};
734
+ const codesByCategory = {};
730
735
  let lastTs = null;
731
736
  let hasErrors = false;
732
737
  for (const line of lines) {
@@ -734,24 +739,38 @@ function checkCaptureLog() {
734
739
  const entry = JSON.parse(line);
735
740
  const category = entry.reason || entry.msg || 'unknown';
736
741
  counts[category] = (counts[category] || 0) + 1;
742
+ if (entry.code) {
743
+ (codesByCategory[category] ??= {});
744
+ codesByCategory[category][entry.code] = (codesByCategory[category][entry.code] || 0) + 1;
745
+ }
737
746
  lastTs = entry.ts;
738
747
  if (entry.msg) hasErrors = true;
739
748
  } catch { /* non-JSON line */ }
740
749
  }
741
750
 
742
751
  const total = lines.length;
743
- const breakdown = Object.entries(counts).map(([r, n]) => `${r}: ${n}`).join(', ');
752
+ const breakdown = Object.entries(counts).map(([r, n]) => {
753
+ const codes = codesByCategory[r];
754
+ if (!codes) return `${r}: ${n}`;
755
+ const codeStr = Object.entries(codes).map(([c, cn]) => `${c}: ${cn}`).join(', ');
756
+ return `${r}: ${n} [${codeStr}]`;
757
+ }).join(', ');
744
758
 
745
759
  let summary = `${total} entries`;
746
760
  if (breakdown) summary += ` (${breakdown})`;
747
761
  if (lastTs) summary += `, last ${relativeTime(lastTs)}`;
748
762
 
763
+ // Include a compact code-distribution block in detail too — survives even if
764
+ // the "last 10 entries" happen to be benign project_filter lines.
765
+ const codeLines = Object.entries(codesByCategory)
766
+ .map(([cat, codes]) => ` ${cat}: ${Object.entries(codes).map(([c, n]) => `${c}=${n}`).join(', ')}`);
767
+ const codeBlock = codeLines.length ? `\n\nError codes by category:\n${codeLines.join('\n')}` : '';
749
768
  const last10 = lines.slice(-10);
750
769
 
751
770
  return {
752
771
  ok: !hasErrors,
753
772
  summary,
754
- detail: `Log: ${CAPTURE_LOG_PATH}\nTotal: ${total}\n\nLast 10 entries:\n${last10.join('\n')}`,
773
+ detail: `Log: ${CAPTURE_LOG_PATH}\nTotal: ${total}${codeBlock}\n\nLast 10 entries:\n${last10.join('\n')}`,
755
774
  };
756
775
  }
757
776
 
package/client/capture.js CHANGED
@@ -1263,7 +1263,7 @@ async function main() {
1263
1263
  writeToSpool(ev);
1264
1264
  if (ev === primary) primaryWritten = true;
1265
1265
  } catch (err) {
1266
- captureLog({ msg: 'queue-write-failed', error: err.message, type: ev.type, session_id: ev.session_id });
1266
+ captureLog({ msg: 'queue-write-failed', error: err.message, code: err.code, type: ev.type, session_id: ev.session_id });
1267
1267
  // If the primary failed, propagate the failure so the hook exits non-zero
1268
1268
  // (and dedup is NOT committed). If a per-call event failed, log and keep
1269
1269
  // going — losing one TokenUsage row is better than dropping the whole
@@ -1312,14 +1312,14 @@ async function main() {
1312
1312
  try {
1313
1313
  trySpawnSender();
1314
1314
  } catch (err) {
1315
- captureLog({ msg: 'sender-spawn-failed', error: err.message });
1315
+ captureLog({ msg: 'sender-spawn-failed', error: err.message, code: err.code });
1316
1316
  // event is queued — sender will be spawned on next capture
1317
1317
  }
1318
1318
 
1319
1319
  try {
1320
1320
  trySpawnCodexWatcher({ replayExisting: shouldReplayCodexHistory(primary) });
1321
1321
  } catch (err) {
1322
- captureLog({ msg: 'codex-watcher-spawn-failed', error: err.message });
1322
+ captureLog({ msg: 'codex-watcher-spawn-failed', error: err.message, code: err.code });
1323
1323
  }
1324
1324
  }
1325
1325
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-lens",
3
- "version": "0.8.68",
3
+ "version": "0.8.70",
4
4
  "type": "module",
5
5
  "description": "Centralized session analytics for AI coding tools",
6
6
  "bin": {