ai-lens 0.8.120 → 0.8.121

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
- bf6f7d9
1
+ 9775c12
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.121 — 2026-07-21
6
+ - fix(codex): Codex token usage no longer double-counts — some Codex versions re-emit an identical usage record without advancing their own running counter, and those duplicates were inflating token totals; they are now dropped (including duplicates that land in a later session update)
7
+
5
8
  ## 0.8.120 — 2026-07-03
6
9
  - fix: session events are no longer misattributed to an unrelated repository when a tool touches a file outside the project — project refinement now only follows genuine nested sub-repos, so the `projects` filter and per-project stats stay correct
7
10
  - feat(codex): Codex sessions now report per-call token usage, matching Claude Code / Cursor granularity
package/README.md CHANGED
@@ -345,13 +345,15 @@ Stored in `~/.ai-lens/`:
345
345
  ## Development
346
346
 
347
347
  ```bash
348
- npm test # Run all tests (vitest, 683 tests)
348
+ npm test # Run all tests (vitest)
349
349
  npm run test:watch # Watch mode
350
350
  npm run dev:dashboard # Dashboard dev server
351
351
  ```
352
352
 
353
353
  Tests require PostgreSQL — set `DATABASE_URL` or use `docker compose up postgres -d` (test DB `ailens_test` is created automatically).
354
354
 
355
+ Test files run in parallel, each worker against its own database: `test/global-setup.js` migrates a template once and clones it into `ailens_test_w1..wN`. Set `AI_LENS_TEST_WORKERS` to change the worker count (default 6).
356
+
355
357
  Architecture decisions are recorded under [`docs/adr/`](docs/adr/) — e.g. [ADR 0001 — Canonical metric predicates](docs/adr/0001-canonical-metric-predicates.md).
356
358
 
357
359
  ## Deployment
package/cli/hooks.js CHANGED
@@ -733,6 +733,27 @@ export function getClaudeCodeToolConfig(projectRoot, label = 'Claude Code (proje
733
733
  };
734
734
  }
735
735
 
736
+ /**
737
+ * The per-machine Claude Code overlay as a tool config: same tool, but pointed at
738
+ * `<project>/.claude/settings.local.json`.
739
+ *
740
+ * This file is where `init` ACTUALLY writes project hooks (cli/init.js — gitignored,
741
+ * per-machine, HELP-1539), so anything that inspects "the project's Claude hooks" and
742
+ * reads only `settings.json` is looking at the wrong file. It has its own label
743
+ * because consumers key on `tool.name`: `status`'s capture test emits one `[<name>]`
744
+ * block per tested tool and the server parses those blocks into a Map — two entries
745
+ * sharing a name would silently overwrite each other.
746
+ *
747
+ * `Claude Code (project local overlay)` is the canonical string (already used by
748
+ * `cli/remove.js` and already mapped to the `claude_code` source in
749
+ * `server/models/client-reports.js`). Do not invent a variant.
750
+ */
751
+ export function getClaudeCodeLocalToolConfig(projectRoot, label = 'Claude Code (project local overlay)', ctx = null) {
752
+ const base = getClaudeCodeToolConfig(projectRoot, label, ctx);
753
+ if (!base) return null;
754
+ return { ...base, configPath: claudeLocalSettingsPath(base), sharedConfig: true };
755
+ }
756
+
736
757
  export function getCodexToolConfig(projectRoot, label = 'Codex (project)', ctx = null) {
737
758
  const base = TOOL_CONFIGS.find(t => t.name === 'Codex');
738
759
  if (!base) return null;
@@ -1226,9 +1247,7 @@ export function detectAiLensInstallScope(projectRoot) {
1226
1247
  // (per-machine, gitignored); the committed `--use-repo-path` form lands in
1227
1248
  // settings.json — check BOTH so neither install style is missed.
1228
1249
  const claudeProject = getClaudeCodeToolConfig(projectRoot);
1229
- const claudeProjectLocal = claudeProject
1230
- ? { ...claudeProject, configPath: claudeLocalSettingsPath(claudeProject) }
1231
- : null;
1250
+ const claudeProjectLocal = getClaudeCodeLocalToolConfig(projectRoot);
1232
1251
  const projectTools = [
1233
1252
  claudeProject,
1234
1253
  claudeProjectLocal,
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, CONHOST_HEADLESS_PREFIX_RE } from './hooks.js';
10
+ import { getVersionInfo, readLensConfig, detectInstalledTools, getCursorToolConfig, getClaudeCodeToolConfig, getCodexToolConfig, getClaudeCodeLocalToolConfig, analyzeToolHooks, checkHooksDisabled, verifyCodexHookTrust, CAPTURE_PATH, TOOL_CONFIGS, isClaudeProjectDirCommand, isWrongPlatformProjectDirCommand, 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 { recoverMojibake } from '../client/mojibake-fix.js';
13
13
  import { isProjectMonitored } from '../client/capture.js';
@@ -234,17 +234,49 @@ function validateHookCommandPaths(tool) {
234
234
  }
235
235
 
236
236
  /**
237
- * Global tools (detectInstalledTools) plus project-level tools when cwd has .cursor/hooks.json
238
- * or .claude/settings.json. Status is assumed to be run from the project root where hooks live.
237
+ * Global tools (detectInstalledTools) plus the project-level tools found under
238
+ * `projectRoot`. Status is assumed to be run from the project root where hooks live.
239
+ *
240
+ * Claude Code contributes up to TWO entries, and both halves matter:
241
+ *
242
+ * - `settings.local.json` (the OVERLAY) is where `init` actually writes project
243
+ * hooks — per-machine, gitignored (HELP-1539). Reading only `settings.json` is
244
+ * how this check used to report `no hook command found` for a perfectly healthy
245
+ * install, while the per-tool line in the SAME report said the overlay was fine.
246
+ *
247
+ * - the committed `settings.json` is tested only when its hooks can actually fire
248
+ * HERE. When `analyzeClaudeLocalOverlay` says an overlay is `applicable`, that
249
+ * means by definition "this file carries the other OS's syntax and never fires on
250
+ * this machine" — running such a command through the shell yields exit 127 and
251
+ * would report `capture failed` for someone whose capture works via the overlay.
252
+ * Testing a command the OS cannot execute proves nothing; skipping it makes this
253
+ * check agree with the per-tool line instead of contradicting it.
254
+ *
255
+ * @param {string} [projectRoot] — defaults to cwd; a parameter so this is testable
256
+ * without chdir (it had no coverage precisely because it took none).
257
+ * @param {{platform?: string, tracked?: boolean}} [opts] — overlay-analysis
258
+ * overrides, forwarded to analyzeClaudeLocalOverlay (tests only).
239
259
  */
240
- function getToolsForCaptureTest() {
260
+ export function getToolsForCaptureTest(projectRoot = process.cwd(), opts = {}) {
241
261
  const global = detectInstalledTools();
242
- const projectRoot = process.cwd();
243
262
  const projectTools = [];
244
263
  const cursorProject = getCursorToolConfig(projectRoot);
245
264
  if (cursorProject && existsSync(cursorProject.configPath)) projectTools.push(cursorProject);
265
+
246
266
  const claudeProject = getClaudeCodeToolConfig(projectRoot);
247
- if (claudeProject && existsSync(claudeProject.configPath)) projectTools.push(claudeProject);
267
+ if (claudeProject && existsSync(claudeProject.configPath)) {
268
+ const overlay = analyzeClaudeLocalOverlay(claudeProject, opts);
269
+ // `applicable` ⇔ wrong-platform syntax AND git-tracked. The untracked variant is
270
+ // just as unrunnable, so check the command form directly too.
271
+ const wrongForm = isWrongPlatformProjectDirCommand(
272
+ extractHookCommand(claudeProject) ?? '', opts.platform ?? process.platform,
273
+ );
274
+ if (!overlay.applicable && !wrongForm) projectTools.push(claudeProject);
275
+ }
276
+
277
+ const claudeLocal = getClaudeCodeLocalToolConfig(projectRoot);
278
+ if (claudeLocal && existsSync(claudeLocal.configPath)) projectTools.push(claudeLocal);
279
+
248
280
  const codexProject = getCodexToolConfig(projectRoot);
249
281
  if (codexProject && existsSync(codexProject.configPath)) projectTools.push(codexProject);
250
282
  return [...global, ...projectTools];
@@ -260,7 +292,22 @@ function checkCaptureRun(installedTools) {
260
292
  }
261
293
 
262
294
  if (toolCommands.length === 0) {
263
- return { ok: null, summary: 'no hook command found', detail: 'Could not find an AI Lens hook command in any tool config' };
295
+ // Say WHERE we looked. Project hooks are discovered relative to cwd, so running
296
+ // status from outside the project produces this result on a perfectly healthy
297
+ // install — and the bare old wording ("no hook command found") read as "capture is
298
+ // dead". Three developers currently sit in exactly this state while their own
299
+ // report shows capture happening seconds earlier.
300
+ const looked = installedTools.map(t => t.configPath);
301
+ return {
302
+ ok: null,
303
+ summary: looked.length
304
+ ? `no hook command found in the configs checked here (${looked.length}) — project hooks may live in another directory`
305
+ : 'no tool configs found to check',
306
+ detail: 'Could not find an AI Lens hook command in any tool config.\n'
307
+ + `Checked:\n${looked.map(p => ` ${p}`).join('\n') || ' (none)'}\n\n`
308
+ + 'Project hooks are looked up relative to the current directory. If your hooks are\n'
309
+ + 'installed per-project, re-run `ai-lens status` from that project root.',
310
+ };
264
311
  }
265
312
 
266
313
  const defaultCaptureScript = join(homedir(), '.ai-lens', 'client', 'capture.js');
package/client/capture.js CHANGED
@@ -401,9 +401,18 @@ function readNewTranscriptDelta(transcriptPath) {
401
401
  const processable = buffer.slice(0, lastNewlineIndex).toString('utf-8');
402
402
  const newOffset = startOffset + lastNewlineIndex + 1;
403
403
  const nextState = { offset: newOffset, size: currentSize, mtime_ms: currentMtime };
404
- const commitOffset = () => writeTranscriptOffsetState(offsetFile, nextState);
405
-
406
- return { lines: processable.split('\n'), commitOffset };
404
+ // `extra` lets a caller persist source-specific cursor state (e.g. Codex's
405
+ // running cumulative token total, ANL-1343) atomically WITH the byte offset —
406
+ // so on a crash-before-commit both roll back together and nothing drifts.
407
+ const commitOffset = (extra) => writeTranscriptOffsetState(
408
+ offsetFile,
409
+ extra ? { ...nextState, ...extra } : nextState,
410
+ );
411
+
412
+ // `savedState` is the offset state as it stood BEFORE this delta (may carry
413
+ // extra fields a prior commit persisted). Exposed so a source-specific
414
+ // extractor can seed cross-delta state without re-reading the offset file.
415
+ return { lines: processable.split('\n'), commitOffset, savedState };
407
416
  }
408
417
 
409
418
  /**
@@ -563,12 +572,22 @@ function extractNewClaudeApiCallsFromTranscript(transcriptPath) {
563
572
  * commitOffset: function}}
564
573
  */
565
574
  function extractNewCodexRecordsFromTranscript(transcriptPath, fallbackModel) {
566
- const { lines, commitOffset } = readNewTranscriptDelta(transcriptPath);
575
+ const { lines, commitOffset, savedState } = readNewTranscriptDelta(transcriptPath);
567
576
  if (!lines.length) return { calls: [], messages: [], commitOffset };
568
577
 
569
578
  const calls = [];
570
579
  const messages = [];
571
580
  let currentModel = fallbackModel || null;
581
+ // Highest cumulative token total already counted for this transcript, seeded
582
+ // from the persisted offset state so it survives across Stop deltas. Codex
583
+ // sometimes re-emits an identical token_count without advancing its
584
+ // cumulative counter (`total_token_usage.total_tokens`); the duplicate carries
585
+ // a fresh timestamp, so its event_id differs and the server dedup never
586
+ // collapses it — double-counting tokens (ANL-1343). We drop any token_count
587
+ // whose cumulative does not advance past this max. The cumulative is monotonic
588
+ // and never resets (verified across compactions on 22k+ real records), so a
589
+ // single running max is a complete, false-positive-free signal.
590
+ let maxTotal = toNumberOrNull(savedState?.codex_last_total) ?? 0;
572
591
  try {
573
592
  for (const rawLine of lines) {
574
593
  const line = rawLine && rawLine.trim();
@@ -647,6 +666,15 @@ function extractNewCodexRecordsFromTranscript(transcriptPath, fallbackModel) {
647
666
  // record and identical across two concurrent Stop hooks reading the same
648
667
  // bytes.
649
668
  const cum = toNumberOrNull(info?.total_token_usage?.total_tokens);
669
+ // Phantom-duplicate guard (ANL-1343): skip a token_count whose cumulative
670
+ // total does not advance past the max already counted for this transcript.
671
+ // Every such record is a byte-identical re-emission (verified: 100% literal
672
+ // duplicates, 0 false positives). cum-less records (older Codex without a
673
+ // total) fall through unchanged and rely on the existing dedup.
674
+ if (cum != null) {
675
+ if (cum <= maxTotal) continue;
676
+ maxTotal = cum;
677
+ }
650
678
  const uuid = `${ts || ''}:${cum != null ? cum : ''}`;
651
679
 
652
680
  calls.push({
@@ -663,11 +691,14 @@ function extractNewCodexRecordsFromTranscript(transcriptPath, fallbackModel) {
663
691
  }
664
692
  } catch (err) {
665
693
  captureLog({ msg: 'transcript-offset-error', stage: 'parse-codex', path: transcriptPath, error: err?.message });
666
- commitOffset();
694
+ commitOffset({ codex_last_total: maxTotal });
667
695
  return { calls: [], messages: [], commitOffset: () => {} };
668
696
  }
669
697
 
670
- return { calls, messages, commitOffset };
698
+ // Persist the advanced cumulative max together with the byte offset, so the
699
+ // next Stop delta seeds `maxTotal` correctly and cross-delta duplicates are
700
+ // still caught. Deferred: the caller invokes this only AFTER spooling.
701
+ return { calls, messages, commitOffset: () => commitOffset({ codex_last_total: maxTotal }) };
671
702
  }
672
703
 
673
704
  // =============================================================================
@@ -690,10 +721,19 @@ export function detectSource(event) {
690
721
  // Codex writes its rollout transcript as `.../sessions/.../rollout-<ts>-<id>.jsonl`
691
722
  // (codex-rs session naming); Claude Code uses `~/.claude/projects/<dir>/<uuid>.jsonl`.
692
723
  // The `rollout-*.jsonl` basename is the reliable Codex signal for SessionStart.
724
+ // But some Codex SessionStart events carry an EMPTY transcript_path (no rollout yet) and
725
+ // no turn_id, so they'd fall through to claude_code — which mislabeled the whole session
726
+ // downstream ([0132]). Those events carry `model: "gpt-5.x…"`; Claude Code never emits a
727
+ // `gpt-*` model, so it's a safe Codex tie-breaker — but ONLY for the claude-code-shaped
728
+ // envelope: Cursor also emits `gpt-*` models, so gate the model signal behind "no Cursor
729
+ // signal present" (conversation_id/cursor_version), which the Cursor branch below keys on.
693
730
  const transcriptPath = typeof event.transcript_path === 'string' ? event.transcript_path : '';
731
+ const model = typeof event.model === 'string' ? event.model : '';
732
+ const gptModelNoCursor = /^gpt[-.]/i.test(model) && !event.conversation_id && !event.cursor_version;
694
733
  if (event.codex_version || event.source === 'codex' || event.hook_source === 'codex'
695
734
  || typeof event.turn_id === 'string'
696
- || /(^|[\\/])rollout-[^\\/]*\.jsonl$/.test(transcriptPath)) return 'codex';
735
+ || /(^|[\\/])rollout-[^\\/]*\.jsonl$/.test(transcriptPath)
736
+ || gptModelNoCursor) return 'codex';
697
737
  if (event.conversation_id || event.cursor_version) return 'cursor';
698
738
  if (event.session_id || event.transcript_path) return 'claude_code';
699
739
  return 'unknown';
package/client/redact.js CHANGED
@@ -90,6 +90,10 @@ const PATTERNS = [
90
90
  // Meilisearch master/API key (mc_ + 32+ hex chars)
91
91
  { type: 'MEILISEARCH_KEY', re: /\bmc_[0-9a-f]{32,}\b/g },
92
92
 
93
+ // Cursor Admin API key (crsr_ + 64 hex chars as issued today; 32+ to stay
94
+ // ahead of a longer future format)
95
+ { type: 'CURSOR_ADMIN_KEY', re: /\bcrsr_[0-9a-f]{32,}\b/g },
96
+
93
97
  // Connection string password (://user:password@host) — redacts password only.
94
98
  // Tightened: exclude JSON structural chars from the user/password classes so
95
99
  // a stringified JSON payload (e.g. MCP `tool_response` kept as a single
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-lens",
3
- "version": "0.8.120",
3
+ "version": "0.8.121",
4
4
  "type": "module",
5
5
  "description": "Centralized session analytics for AI coding tools",
6
6
  "bin": {