ai-lens 0.8.72 → 0.8.74
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 +1 -1
- package/CHANGELOG.md +7 -0
- package/README.md +2 -0
- package/cli/hooks.js +63 -7
- package/cli/init.js +22 -4
- package/client/capture.js +27 -1
- package/package.json +1 -1
package/.commithash
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
87a4956
|
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.74 — 2026-06-04
|
|
6
|
+
- fix: committed Claude Code project hooks now resolve `capture.js` via `$CLAUDE_PROJECT_DIR`, so they fire from any working directory — no more `MODULE_NOT_FOUND` after the agent `cd`s into a subdirectory.
|
|
7
|
+
- fix: `ai-lens status` and `ai-lens init --project-hooks` recognize the `$CLAUDE_PROJECT_DIR` hook form as current instead of flagging it outdated or overwriting it.
|
|
8
|
+
|
|
9
|
+
## 0.8.73 — 2026-05-29
|
|
10
|
+
- fix: spooling an event now retries the atomic `rename` in `~/.ai-lens/pending/` a few times with a short backoff before giving up. On Windows, antivirus/Defender or the search indexer transiently locks the temp file and the rename failed with EPERM, silently dropping that event (observed in prod via the 0.8.70 error-code instrumentation). POSIX is unaffected (its rename is atomic and never hits this).
|
|
11
|
+
|
|
5
12
|
## 0.8.72 — 2026-05-29
|
|
6
13
|
- fix: `status` no longer reports hooks as "outdated" when they already capture reliably. A hook is now considered current if it's GUI-safe — either the per-machine launcher (`run.sh`/`run.cmd`, including the transitional `sh -c` wrapper) OR a `capture.js` command with an absolute node path baked in (e.g. `/opt/homebrew/bin/node`). Only PATH-dependent forms (bare `node`, `/usr/bin/env node`) — which break for GUI-launched Cursor/Claude on macOS — stay flagged outdated so `init` rewrites them.
|
|
7
14
|
- fix: `ai-lens status --report` now prints the full status to the screen just like plain `ai-lens status`, in addition to sending the report to the server. Previously it ran silently. The only difference from plain status is that it POSTs the report instead of writing the local `~/ai-lens-status.txt` file.
|
package/README.md
CHANGED
|
@@ -308,6 +308,8 @@ npm run dev:dashboard # Dashboard dev server
|
|
|
308
308
|
|
|
309
309
|
Tests require PostgreSQL — set `DATABASE_URL` or use `docker compose up postgres -d` (test DB `ailens_test` is created automatically).
|
|
310
310
|
|
|
311
|
+
Architecture decisions are recorded under [`docs/adr/`](docs/adr/) — e.g. [ADR 0001 — Canonical metric predicates](docs/adr/0001-canonical-metric-predicates.md).
|
|
312
|
+
|
|
311
313
|
## Deployment
|
|
312
314
|
|
|
313
315
|
GitLab CI (`.gitlab-ci.yml`) on push to `main`:
|
package/cli/hooks.js
CHANGED
|
@@ -322,6 +322,8 @@ function resolveDefaultCtx() {
|
|
|
322
322
|
* @param {boolean} [opts.useTilde] — write ~/.ai-lens/client/... (portable path for project-hooks)
|
|
323
323
|
* @param {boolean} [opts.rawPath] — don't quote the target (Claude Code config wants raw)
|
|
324
324
|
* @param {string} [opts.customPath] — absolute repo-mode path to capture.js (--use-repo-path)
|
|
325
|
+
* @param {string} [opts.projectDirRelPath] — capture.js path relative to the workspace root,
|
|
326
|
+
* emitted as a Claude-Code-only `node "$CLAUDE_PROJECT_DIR/<rel>"` command (committed project hooks)
|
|
325
327
|
* @param {object} [opts.ctx] — { nodeResolution, platform, clientDir }; lazy-resolved if omitted
|
|
326
328
|
*/
|
|
327
329
|
export function captureCommand(opts = {}) {
|
|
@@ -329,7 +331,7 @@ export function captureCommand(opts = {}) {
|
|
|
329
331
|
if (typeof opts === 'boolean') {
|
|
330
332
|
opts = { useTilde: opts, rawPath: arguments[1], customPath: arguments[2] ?? null };
|
|
331
333
|
}
|
|
332
|
-
const { useTilde = false, rawPath = false, customPath = null, shell = null } = opts;
|
|
334
|
+
const { useTilde = false, rawPath = false, customPath = null, shell = null, projectDirRelPath = null } = opts;
|
|
333
335
|
const ctx = opts.ctx ?? resolveDefaultCtx();
|
|
334
336
|
// Tolerate partial ctx (e.g. only nodeResolution supplied) by filling in module
|
|
335
337
|
// defaults — keeps callers from having to repeat platform/clientDir everywhere.
|
|
@@ -341,6 +343,20 @@ export function captureCommand(opts = {}) {
|
|
|
341
343
|
// Windows — the two need different escaping for paths with spaces.
|
|
342
344
|
const isPS = shell === 'powershell';
|
|
343
345
|
|
|
346
|
+
// Claude Code committed project hooks: resolve capture.js via Claude Code's
|
|
347
|
+
// $CLAUDE_PROJECT_DIR (the workspace root, injected into the hook environment).
|
|
348
|
+
// Claude Code runs the command through a shell, so the var expands regardless of
|
|
349
|
+
// the agent's cwd — a path relative to cwd breaks the moment the agent cd's into a
|
|
350
|
+
// subdir/subrepo (MODULE_NOT_FOUND). Because this string is committed to the repo
|
|
351
|
+
// it must stay machine-agnostic: bare `node` + the env var, never an absolute node
|
|
352
|
+
// binary or /home/<user> path. Windows Claude Code shells via cmd.exe (%VAR%);
|
|
353
|
+
// POSIX shells use $VAR.
|
|
354
|
+
if (projectDirRelPath != null) {
|
|
355
|
+
const rel = projectDirRelPath.replace(/\\/g, '/').replace(/^\.?\/+/, '');
|
|
356
|
+
const dir = isWin ? '%CLAUDE_PROJECT_DIR%' : '$CLAUDE_PROJECT_DIR';
|
|
357
|
+
return `node "${dir}/${rel}"`;
|
|
358
|
+
}
|
|
359
|
+
|
|
344
360
|
// --use-repo-path: legacy <node> <capture.js> form.
|
|
345
361
|
if (customPath != null) {
|
|
346
362
|
if (!nodeResolution || !nodeResolution.path) {
|
|
@@ -618,6 +634,26 @@ export function getClaudeCodeHookDefsWithPath(capturePath, ctx = null) {
|
|
|
618
634
|
return defs;
|
|
619
635
|
}
|
|
620
636
|
|
|
637
|
+
/**
|
|
638
|
+
* Claude Code hook defs for committed PROJECT hooks (.claude/settings.json checked
|
|
639
|
+
* into the repo). Resolves capture.js via Claude Code's $CLAUDE_PROJECT_DIR so the
|
|
640
|
+
* hook fires from any cwd and the path stays machine-agnostic (no absolute node/path).
|
|
641
|
+
* This is the cwd-robust replacement for the `--use-repo-path --project-hooks` form,
|
|
642
|
+
* whose project-root-relative path broke when an agent cd'd into a subdir.
|
|
643
|
+
* @param {string} relPath - capture.js path relative to the workspace root
|
|
644
|
+
* (e.g. internal/analytics/ai-lens/client/capture.js). Must contain an `ai-lens/`
|
|
645
|
+
* segment so isAiLensCommand recognises it.
|
|
646
|
+
* @param {object} [ctx]
|
|
647
|
+
*/
|
|
648
|
+
export function getClaudeCodeHookDefsWithProjectDir(relPath, ctx = null) {
|
|
649
|
+
const getCmd = memoizeCmd(() => captureCommand({ projectDirRelPath: relPath, ctx }));
|
|
650
|
+
const defs = {};
|
|
651
|
+
for (const [name, spec] of Object.entries(CLAUDE_HOOK_SPEC)) {
|
|
652
|
+
defs[name] = () => ({ matcher: spec.matcher, hooks: [{ type: 'command', command: getCmd() }] });
|
|
653
|
+
}
|
|
654
|
+
return defs;
|
|
655
|
+
}
|
|
656
|
+
|
|
621
657
|
/**
|
|
622
658
|
* Cursor hook defs that point to a custom path (e.g. REPO_CAPTURE_PATH for --use-repo-path).
|
|
623
659
|
* @param {string} capturePath - Absolute path to client/capture.js.
|
|
@@ -879,19 +915,39 @@ export function isGuiSafeHookCommand(cmd) {
|
|
|
879
915
|
return false;
|
|
880
916
|
}
|
|
881
917
|
|
|
918
|
+
// A committed Claude Code PROJECT hook that resolves capture.js through Claude Code's
|
|
919
|
+
// $CLAUDE_PROJECT_DIR ($VAR on POSIX, %VAR% on Windows). This form intentionally uses
|
|
920
|
+
// bare `node` (it's checked into the repo and must work on every teammate's machine,
|
|
921
|
+
// so it can't bake an absolute node path), and Claude Code guarantees the var points
|
|
922
|
+
// at the workspace root regardless of cwd. The var is Claude-Code-specific and never
|
|
923
|
+
// emitted for Cursor/Codex, so matching it here can't mis-accept another tool's hook.
|
|
924
|
+
export function isClaudeProjectDirCommand(cmd) {
|
|
925
|
+
if (!isAiLensCommand(cmd).isAiLens) return false;
|
|
926
|
+
const n = (cmd || '').replace(/\\/g, '/');
|
|
927
|
+
return /\$CLAUDE_PROJECT_DIR|%CLAUDE_PROJECT_DIR%/.test(n)
|
|
928
|
+
&& /ai-lens\/client\/capture\.js/.test(n);
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
// A hook command init/status should leave alone: either GUI-safe (launcher /
|
|
932
|
+
// absolute-node capture.js) OR the committed Claude Code $CLAUDE_PROJECT_DIR form.
|
|
933
|
+
function isAcceptableHookCommand(cmd) {
|
|
934
|
+
return isGuiSafeHookCommand(cmd) || isClaudeProjectDirCommand(cmd);
|
|
935
|
+
}
|
|
936
|
+
|
|
882
937
|
function isCurrentAiLensHook(entry, expected) {
|
|
883
|
-
// "Current" = a GUI-safe install (launcher OR absolute-node capture.js)
|
|
884
|
-
//
|
|
885
|
-
//
|
|
886
|
-
//
|
|
938
|
+
// "Current" = a GUI-safe install (launcher OR absolute-node capture.js) OR a
|
|
939
|
+
// committed Claude Code $CLAUDE_PROJECT_DIR project hook. We do NOT require an exact
|
|
940
|
+
// match against the expected command form — every valid install method captures
|
|
941
|
+
// reliably, so none should be reported outdated. Only PATH-dependent forms
|
|
942
|
+
// (bare `node`, `/usr/bin/env node`) WITHOUT $CLAUDE_PROJECT_DIR are outdated.
|
|
887
943
|
// Flat format (Cursor): single command per entry.
|
|
888
944
|
if (entry?.command != null) {
|
|
889
|
-
return
|
|
945
|
+
return isAcceptableHookCommand(entry.command);
|
|
890
946
|
}
|
|
891
947
|
// Nested format (Claude Code / Codex): { matcher, hooks: [{ command }] }
|
|
892
948
|
if (Array.isArray(entry?.hooks)) {
|
|
893
949
|
if (expected?.matcher !== undefined && entry.matcher !== expected.matcher) return false;
|
|
894
|
-
return entry.hooks.some(h =>
|
|
950
|
+
return entry.hooks.some(h => isAcceptableHookCommand(h?.command || ''));
|
|
895
951
|
}
|
|
896
952
|
return false;
|
|
897
953
|
}
|
package/cli/init.js
CHANGED
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
CAPTURE_PATH, REPO_CAPTURE_PATH, detectInstalledTools, getCursorToolConfig, getClaudeCodeToolConfig, getCodexToolConfig,
|
|
17
17
|
analyzeToolHooks, buildMergedConfig, writeHooksConfig, describePlan, enableCodexHookTrust,
|
|
18
18
|
installClientFiles, readLensConfig, saveLensConfig, getVersionInfo,
|
|
19
|
-
getClaudeCodeHookDefsWithPath, getCursorHookDefsWithPath, getCodexHookDefsWithPath,
|
|
19
|
+
getClaudeCodeHookDefsWithPath, getClaudeCodeHookDefsWithProjectDir, getCursorHookDefsWithPath, getCodexHookDefsWithPath,
|
|
20
20
|
cleanupLegacyHooks, cleanupOppositeScope, cleanupEmptyMcpJson, addCursorMcp, removeCursorMcp,
|
|
21
21
|
checkHooksDisabled, enableHooks,
|
|
22
22
|
findStableNodePath, isVersionPinnedNodePath, writeLauncher,
|
|
@@ -177,7 +177,14 @@ function getTrackedRoots(projects, fallbackRoot) {
|
|
|
177
177
|
function makeNestedClaudeTool(projectDir, capturePathInHooks, ctx = null) {
|
|
178
178
|
const tool = getClaudeCodeToolConfig(projectDir, `Claude Code (${projectDir})`, ctx);
|
|
179
179
|
if (capturePathInHooks) {
|
|
180
|
-
|
|
180
|
+
// A relative path carrying the ai-lens/ segment is resolved via Claude Code's
|
|
181
|
+
// $CLAUDE_PROJECT_DIR (the nested project's own root) so the hook fires from any
|
|
182
|
+
// cwd. Absolute/tilde paths keep the legacy <node> <capture.js> form.
|
|
183
|
+
const rel = capturePathInHooks.replace(/\\/g, '/');
|
|
184
|
+
const isRelative = !rel.startsWith('/') && !rel.startsWith('~') && !/^[a-zA-Z]:\//.test(rel);
|
|
185
|
+
tool.hookDefs = (isRelative && /(?:^|\/)ai-lens\//.test(rel))
|
|
186
|
+
? getClaudeCodeHookDefsWithProjectDir(rel, ctx)
|
|
187
|
+
: getClaudeCodeHookDefsWithPath(capturePathInHooks, ctx);
|
|
181
188
|
}
|
|
182
189
|
return tool;
|
|
183
190
|
}
|
|
@@ -458,6 +465,10 @@ export default async function init() {
|
|
|
458
465
|
const repoPathAbs = resolve(REPO_CAPTURE_PATH);
|
|
459
466
|
const home = homedir();
|
|
460
467
|
let pathInHooks;
|
|
468
|
+
// When set, Claude Code project hooks resolve capture.js via $CLAUDE_PROJECT_DIR
|
|
469
|
+
// (cwd-robust + machine-agnostic) instead of a cwd-relative path that breaks the
|
|
470
|
+
// moment an agent cd's into a subdir. Holds the workspace-root-relative path.
|
|
471
|
+
let claudeProjectDirRel = null;
|
|
461
472
|
if (flags.projectHooks) {
|
|
462
473
|
const projectRoot = resolve(process.cwd());
|
|
463
474
|
pathInHooks = relative(projectRoot, repoPathAbs).replace(/\\/g, '/');
|
|
@@ -472,7 +483,10 @@ export default async function init() {
|
|
|
472
483
|
: repoPathAbs.replace(/\\/g, '/');
|
|
473
484
|
info(' Project root is inside ai-lens itself — using portable path with ai-lens/ segment for hook recognition.');
|
|
474
485
|
} else {
|
|
475
|
-
|
|
486
|
+
// Relative path carries the ai-lens/ segment → Claude Code can resolve it via
|
|
487
|
+
// $CLAUDE_PROJECT_DIR. The plain relative path stays the form for Cursor/Codex.
|
|
488
|
+
claudeProjectDirRel = pathInHooks;
|
|
489
|
+
info(' Claude Code project hooks will resolve capture.js via $CLAUDE_PROJECT_DIR (cwd-independent).');
|
|
476
490
|
}
|
|
477
491
|
} else {
|
|
478
492
|
pathInHooks = repoPathAbs.startsWith(home)
|
|
@@ -488,7 +502,11 @@ export default async function init() {
|
|
|
488
502
|
? ('~' + repoPathAbs.slice(home.length)).replace(/\\/g, '/')
|
|
489
503
|
: repoPathAbs.replace(/\\/g, '/');
|
|
490
504
|
for (const tool of tools) {
|
|
491
|
-
if (tool.name.startsWith('Claude Code'))
|
|
505
|
+
if (tool.name.startsWith('Claude Code')) {
|
|
506
|
+
tool.hookDefs = claudeProjectDirRel
|
|
507
|
+
? getClaudeCodeHookDefsWithProjectDir(claudeProjectDirRel, ctx)
|
|
508
|
+
: getClaudeCodeHookDefsWithPath(pathInHooks, ctx);
|
|
509
|
+
}
|
|
492
510
|
else if (tool.name.startsWith('Cursor')) tool.hookDefs = getCursorHookDefsWithPath(pathInHooks, ctx);
|
|
493
511
|
else if (tool.name.startsWith('Codex')) tool.hookDefs = getCodexHookDefsWithPath(codexPathInHooks, ctx);
|
|
494
512
|
}
|
package/client/capture.js
CHANGED
|
@@ -1182,6 +1182,32 @@ export function normalizeEvent(event) {
|
|
|
1182
1182
|
// Queue + Sender Spawn
|
|
1183
1183
|
// =============================================================================
|
|
1184
1184
|
|
|
1185
|
+
// Windows (Defender / other AV, search indexer, file-locks) transiently fails an
|
|
1186
|
+
// atomic rename with EPERM/EACCES/EBUSY while another process briefly holds the
|
|
1187
|
+
// .tmp or destination handle. The rename is our spool-write step, so a transient
|
|
1188
|
+
// failure would drop the event (observed in prod: queue-write-failed EPERM on
|
|
1189
|
+
// rename in ~/.ai-lens/pending). Retry a few times with a short synchronous
|
|
1190
|
+
// backoff before giving up. POSIX rename is atomic and never hits these, so this
|
|
1191
|
+
// is a no-op there. Non-transient errors (e.g. ENOENT) are re-thrown immediately.
|
|
1192
|
+
const RENAME_RETRY_CODES = new Set(['EPERM', 'EACCES', 'EBUSY']);
|
|
1193
|
+
export function renameSyncWithRetry(from, to, { attempts = 5, baseDelayMs = 20, renameFn = renameSync } = {}) {
|
|
1194
|
+
for (let i = 1; ; i++) {
|
|
1195
|
+
try {
|
|
1196
|
+
renameFn(from, to);
|
|
1197
|
+
return;
|
|
1198
|
+
} catch (err) {
|
|
1199
|
+
if (i >= attempts || !RENAME_RETRY_CODES.has(err?.code)) throw err;
|
|
1200
|
+
// Synchronous backoff — capture.js runs in a one-shot hook context, no event loop to yield to.
|
|
1201
|
+
try {
|
|
1202
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, baseDelayMs * i);
|
|
1203
|
+
} catch {
|
|
1204
|
+
const end = Date.now() + baseDelayMs * i;
|
|
1205
|
+
while (Date.now() < end) { /* spin fallback if Atomics unavailable */ }
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1185
1211
|
export function writeToSpool(unified) {
|
|
1186
1212
|
// Shallow copy before redaction — do not mutate the caller's object
|
|
1187
1213
|
const toWrite = { ...unified };
|
|
@@ -1198,7 +1224,7 @@ export function writeToSpool(unified) {
|
|
|
1198
1224
|
const tmpPath = dstPath + '.tmp.' + process.pid;
|
|
1199
1225
|
writeFileSync(tmpPath, JSON.stringify(toWrite));
|
|
1200
1226
|
try {
|
|
1201
|
-
|
|
1227
|
+
renameSyncWithRetry(tmpPath, dstPath);
|
|
1202
1228
|
} catch (err) {
|
|
1203
1229
|
// Clean up orphaned tmp file (e.g. antivirus/file-lock on Windows)
|
|
1204
1230
|
try { unlinkSync(tmpPath); } catch {}
|