ai-lens 0.8.102 → 0.8.104

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
- 3747fae
1
+ 73ea070
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.104 — 2026-06-18
6
+ - fix: subagent token usage is now captured for Claude Code. Tokens spent by Task / general-purpose subagents were silently dropped, so subagent-heavy sessions were badly undercounted — only the main thread's tokens showed up. Capture now reads each subagent's own transcript on `SubagentStop` instead of re-reading the parent session's.
7
+
8
+ ## 0.8.103 — 2026-06-18
9
+ - fix: `ai-lens status` no longer falsely reports the client as "outdated" on repo-path installs (where hooks run capture.js straight from a checkout that auto-updates on `git pull`). It now reads the client version from the repo the hooks actually run, instead of an unused leftover copy in `~/.ai-lens/client/` — which could be months stale and made the check show ✗ + "run init" even though the live client was current. The stale copy is now noted as ignored. Copy-mode installs are unchanged.
10
+
5
11
  ## 0.8.102 — 2026-06-17
6
12
  - fix: the per-machine `settings.local.json` overlay for committed Claude Code hooks now also writes the absolute launcher form on Windows (matching the main install path), so a repo whose tracked `settings.json` carries `$CLAUDE_PROJECT_DIR` hooks captures correctly on Windows without manual fix-up. `ai-lens status`/`init` now flag any leftover `$CLAUDE_PROJECT_DIR`/`%CLAUDE_PROJECT_DIR%` Claude hook on Windows as outdated so it migrates to the absolute form.
7
13
  - fix: the Windows windowless hook launcher (`ai-lens-hook.ps1`) now sends the event payload to node as raw UTF-8 bytes (Cyrillic prompts and accented paths are no longer mangled by PowerShell's default codepage) and fails open — on any internal error it exits cleanly and writes nothing to stdout, so a launcher hiccup can never break a Cursor hook or disrupt the session.
package/cli/status.js CHANGED
@@ -103,10 +103,12 @@ function expandTilde(pathStr) {
103
103
  }
104
104
 
105
105
  /**
106
- * Detect install mode from the capture.js path in hook commands.
107
- * Returns { ok, summary, detail } for the status output.
106
+ * Resolve the capture.js / launcher path(s) the hooks actually run, tagging each
107
+ * with its install mode. Shared by detectInstallMode and checkClientFiles so both
108
+ * reason about the SAME path the hooks execute (not an unrelated copy).
109
+ * Returns [{ tool, raw, resolved, mode: 'copy' | 'repo-path' }].
108
110
  */
109
- function detectInstallMode(tools) {
111
+ export function resolveHookClientPaths(tools) {
110
112
  // Normalise both sides to forward slashes before comparing — captureCommand
111
113
  // always emits forward-slash paths into the hook command, but join() on
112
114
  // Windows returns backslashes, so a raw startsWith() check would miss every
@@ -116,24 +118,40 @@ function detectInstallMode(tools) {
116
118
  for (const tool of tools) {
117
119
  const cmd = extractHookCommand(tool);
118
120
  if (!cmd) continue;
119
- // Match either a launcher (run.sh / run.cmd) or a legacy capture.js path. The launcher
120
- // always lives in the install dir, so it's by definition copy-mode.
121
- const m = cmd.match(/["']([^"']*(?:capture\.js|run\.(?:sh|cmd)))["']|(\S*(?:capture\.js|run\.(?:sh|cmd)))/);
122
- if (m) paths.push({ tool: tool.name, raw: m[1] || m[2], resolved: expandTilde(m[1] || m[2]).replace(/\\/g, '/') });
121
+ // Match either a launcher (run.sh / run.cmd / ai-lens-hook.ps1) or a capture.js path.
122
+ // A launcher always lives in the install dir, so it's by definition copy-mode.
123
+ const m = cmd.match(/["']([^"']*(?:capture\.js|run\.(?:sh|cmd)|ai-lens-hook\.ps1))["']|(\S*(?:capture\.js|run\.(?:sh|cmd)|ai-lens-hook\.ps1))/);
124
+ if (!m) continue;
125
+ const raw = m[1] || m[2];
126
+ const resolved = expandTilde(raw).replace(/\\/g, '/');
127
+ paths.push({ tool: tool.name, raw, resolved, mode: resolved.startsWith(copyDir) ? 'copy' : 'repo-path' });
128
+ }
129
+ return paths;
130
+ }
131
+
132
+ /** Read the `version` field from a package.json, or null if unreadable. */
133
+ function readPackageVersion(pkgJsonPath) {
134
+ try {
135
+ const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf-8'));
136
+ return pkg.version || null;
137
+ } catch {
138
+ return null;
123
139
  }
140
+ }
141
+
142
+ /**
143
+ * Detect install mode from the capture.js path in hook commands.
144
+ * Returns { ok, summary, detail } for the status output.
145
+ */
146
+ function detectInstallMode(tools) {
147
+ const paths = resolveHookClientPaths(tools);
124
148
  if (paths.length === 0) {
125
149
  return { ok: null, summary: 'unknown', detail: 'No hook commands found — cannot determine install mode' };
126
150
  }
127
- const modes = paths.map(p => {
128
- if (p.resolved.startsWith(copyDir)) return 'copy';
129
- return 'repo-path';
130
- });
151
+ const modes = paths.map(p => p.mode);
131
152
  const unique = [...new Set(modes)];
132
153
  const mode = unique.length === 1 ? unique[0] : 'mixed';
133
- const detail = paths.map(p => {
134
- const m = p.resolved.startsWith(copyDir) ? 'copy' : 'repo-path';
135
- return ` ${p.tool}: ${m} (${p.raw})`;
136
- }).join('\n');
154
+ const detail = paths.map(p => ` ${p.tool}: ${p.mode} (${p.raw})`).join('\n');
137
155
 
138
156
  if (mode === 'copy') {
139
157
  return { ok: true, summary: 'copy (~/.ai-lens/client/)', detail: `Client files copied to ~/.ai-lens/client/\nUpdate: npx -y ai-lens init --yes\n${detail}` };
@@ -492,7 +510,37 @@ function checkGitIdentity() {
492
510
  return { ok: false, summary: 'not configured', detail: 'Git identity not configured (git config user.email)' };
493
511
  }
494
512
 
495
- function checkClientFiles() {
513
+ export function checkClientFiles(tools = []) {
514
+ // In repo-path mode the hooks run capture.js straight from the repo, so the
515
+ // ~/.ai-lens/client/ copy (if any) is an UNUSED leftover from a prior copy-mode
516
+ // install. Reading that copy's version.json there would falsely flag the client
517
+ // "outdated" while the live client (the repo) is current — so check the version
518
+ // of the path the hooks actually execute.
519
+ const repoHook = resolveHookClientPaths(tools).find(p => p.mode === 'repo-path');
520
+ if (repoHook) {
521
+ // package.json sits one level up from the client/ dir the hook points into.
522
+ const pkgPath = repoHook.resolved.replace(/\/client\/[^/]+$/i, '/package.json');
523
+ const repoVersion = readPackageVersion(pkgPath);
524
+ const copyExists = existsSync(join(homedir(), '.ai-lens', 'client', 'capture.js'));
525
+ let detail = `Hooks run the client from the repo (repo-path mode) — auto-updates on git pull:\n ${repoHook.raw}`;
526
+ detail += repoVersion
527
+ ? `\n Repo client version: ${repoVersion}`
528
+ : `\n Repo client version: unknown (no package.json at ${pkgPath})`;
529
+ if (copyExists) {
530
+ detail += `\n Note: ~/.ai-lens/client/ exists but is UNUSED in repo-path mode (stale leftover from an old copy-install; safe to ignore).`;
531
+ }
532
+ // Healthy as long as the hook-source version is readable. Don't compare against
533
+ // the CLI: `ai-lens status` may run from a global npx CLI while the hooks point
534
+ // at a checkout — that mismatch is expected, not an error. A stale repo checkout
535
+ // surfaces via git (and the version line above), not as a false ✗ here.
536
+ return {
537
+ ok: repoVersion != null,
538
+ summary: repoVersion ? `repo-path (v${repoVersion})` : 'repo-path (version unknown)',
539
+ detail,
540
+ };
541
+ }
542
+
543
+ // Copy-mode (or no hooks resolved): the ~/.ai-lens/client/ copy IS the live client.
496
544
  const dir = join(homedir(), '.ai-lens', 'client');
497
545
  const files = ['capture.js', 'sender.js', 'config.js'];
498
546
  const results = files.map(f => ({ name: f, exists: existsSync(join(dir, f)) }));
@@ -1427,16 +1475,19 @@ export default async function status({ report = false } = {}) {
1427
1475
  // 3. Git identity
1428
1476
  printLine('Git identity', checkGitIdentity());
1429
1477
 
1478
+ // Resolve the tools (incl. project hooks) up front — checkClientFiles needs them
1479
+ // to read the version of the path the hooks actually run (repo-path vs copy).
1480
+ const installedTools = detectInstalledTools();
1481
+ const toolsWithProject = getToolsForCaptureTest();
1482
+
1430
1483
  // 4. Client files
1431
- printLine('Client files', checkClientFiles());
1484
+ printLine('Client files', checkClientFiles(toolsWithProject));
1432
1485
 
1433
1486
  // 5. Config
1434
1487
  const configResult = checkConfig();
1435
1488
  printLine('Config', configResult);
1436
1489
 
1437
1490
  // 6. Hooks: global + project (Cursor then Claude Code; within each: global then project)
1438
- const installedTools = detectInstalledTools();
1439
- const toolsWithProject = getToolsForCaptureTest();
1440
1491
  const isGlobalTool = (tool) => TOOL_CONFIGS.includes(tool);
1441
1492
  const toolLabel = (tool) => (isGlobalTool(tool) ? `${tool.name} (global)` : tool.name);
1442
1493
  const hooksOrder = (a, b) => {
package/client/capture.js CHANGED
@@ -892,12 +892,20 @@ function normalizeClaudeCode(event) {
892
892
  // TokenUsage event per real assistant API call. This is what makes Claude
893
893
  // Code's row-per-call granularity match Cursor and Codex.
894
894
  //
895
- // Claude Code's Stop hook passes the path as `transcript_path`, but
896
- // SubagentStop uses `agent_transcript_path` (see Claude Code hook docs and
897
- // test/fixtures/claude-code-events/subagent-stop.json). Check both so
898
- // subagent token usage is not silently dropped in production.
895
+ // Claude Code's Stop hook passes the path as `transcript_path`. SubagentStop
896
+ // carries BOTH `transcript_path` (the parent session's transcript) AND
897
+ // `agent_transcript_path` (the subagent's own transcript, a separate file in
898
+ // a `subagents/` subdir — the subagent's API calls live ONLY there, not in
899
+ // the parent transcript). So for SubagentStop we MUST prefer
900
+ // `agent_transcript_path`: a `transcript_path || agent_transcript_path`
901
+ // precedence always picks the parent transcript (whose delta is already
902
+ // consumed) and silently drops every subagent call — i.e. all the tokens
903
+ // burned by Task/general-purpose subagents. See
904
+ // test/fixtures/claude-code-events/subagent-stop.json for a real payload.
899
905
  if (hookType === 'Stop' || hookType === 'SubagentStop') {
900
- const transcriptPath = event.transcript_path || event.agent_transcript_path;
906
+ const transcriptPath = hookType === 'SubagentStop'
907
+ ? (event.agent_transcript_path || event.transcript_path)
908
+ : (event.transcript_path || event.agent_transcript_path);
901
909
  const { calls, commitOffset } = extractNewClaudeApiCallsFromTranscript(transcriptPath);
902
910
  const tokenEvents = calls.map(call => ({
903
911
  event_id: null, // assigned in main() from a stable hash of call.uuid
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-lens",
3
- "version": "0.8.102",
3
+ "version": "0.8.104",
4
4
  "type": "module",
5
5
  "description": "Centralized session analytics for AI coding tools",
6
6
  "bin": {