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 +1 -1
- package/CHANGELOG.md +6 -0
- package/cli/status.js +70 -19
- package/client/capture.js +13 -5
- package/package.json +1 -1
package/.commithash
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
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
|
-
*
|
|
107
|
-
*
|
|
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
|
|
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
|
|
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)
|
|
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
|
|
896
|
-
//
|
|
897
|
-
//
|
|
898
|
-
// subagent
|
|
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 =
|
|
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
|