ai-lens 0.8.49 → 0.8.51

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
- b5da117
1
+ 7c4d056
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # AI Lens
2
2
 
3
- Hook-based analytics for AI coding sessions. Captures events from Claude Code and Cursor, normalizes them to a unified format, queues locally, and ships to a centralized server with a web dashboard.
3
+ Analytics for AI coding sessions. Captures hook events from Claude Code and Cursor, and near-real-time session events from Codex, normalizes them to a unified format, queues locally, and ships to a centralized server with a web dashboard.
4
4
 
5
5
  ```
6
6
  Hook fires → capture.js → normalize → queue.jsonl → sender.js → POST /api/events → server → dashboard
@@ -15,9 +15,10 @@ npx -y ai-lens init
15
15
  ```
16
16
 
17
17
  This will:
18
- 1. Detect installed AI tools (Claude Code, Cursor)
18
+ 1. Detect installed AI tools (Claude Code, Cursor, Codex)
19
19
  2. Copy client files to `~/.ai-lens/client/`
20
20
  3. Configure hooks in `~/.claude/settings.json` and/or `~/.cursor/hooks.json`
21
+ 4. Start the Codex watcher for user-level and project-local Codex sessions
21
22
 
22
23
  Re-running is safe — it updates outdated hooks and skips current ones.
23
24
 
@@ -38,7 +39,7 @@ Configure the server URL and optionally filter projects:
38
39
  ```bash
39
40
  # In your shell profile (~/.zshrc, ~/.bashrc)
40
41
  export AI_LENS_SERVER_URL=http://your-server:13300
41
- export AI_LENS_PROJECTS="~/work/, ~/projects/" # optional, default: all
42
+ export AI_LENS_PROJECTS="~/work/, ~/projects/" # optional, default: all; nested repos under these roots are included
42
43
  ```
43
44
 
44
45
  <details>
@@ -76,6 +77,14 @@ export AI_LENS_PROJECTS="~/work/, ~/projects/" # optional, default: all
76
77
  }
77
78
  ```
78
79
 
80
+ **Codex** — no hook file. Run `ai-lens init` to start the local watcher, or start it manually:
81
+
82
+ ```bash
83
+ node ~/.ai-lens/client/codex-watcher.js
84
+ ```
85
+
86
+ The watcher tails the user-level Codex directory (`~/.codex`) and any project-local `.codex` directories found under `AI_LENS_PROJECTS`. It respects `AI_LENS_PROJECTS` the same way as Claude Code and Cursor: only sessions whose `cwd` is inside a configured monitored root are sent.
87
+
79
88
  </details>
80
89
 
81
90
  ## Server Setup
@@ -103,11 +112,12 @@ Default credentials:
103
112
  ### Local development
104
113
 
105
114
  ```bash
115
+ docker compose up postgres -d
106
116
  npm install --prefix server # Install server deps (auto-runs via prestart)
107
- npm start # Express on port 3000, SQLite at ~/.ai-lens-server/data.db
117
+ DATABASE_URL=postgresql://ailens:ailens@localhost:5432/ailens npm start
108
118
  ```
109
119
 
110
- SQLite is used when `DATABASE_URL` is not set. PostgreSQL is used in Docker via `DATABASE_URL=postgresql://...`.
120
+ The server now requires PostgreSQL via `DATABASE_URL`. The old SQLite fallback is no longer supported.
111
121
 
112
122
  ## Dashboard
113
123
 
@@ -181,7 +191,7 @@ Aggregate endpoints for dashboard charts (stats, trends, tool usage, etc.).
181
191
  | Variable | Default | Description |
182
192
  |----------|---------|-------------|
183
193
  | `PORT` | `3000` (local), `13300` (Docker) | Server port |
184
- | `DATABASE_URL` | _(unset = SQLite)_ | PostgreSQL connection string |
194
+ | `DATABASE_URL` | _(required)_ | PostgreSQL connection string |
185
195
  | `AI_LENS_SERVER_URL` | `http://localhost:3000` | Client → server endpoint |
186
196
  | `AI_LENS_AUTH_TOKEN` | `collector:secret-collector-token-2026-ai-lens` | Client auth (`user:password`) |
187
197
  | `AI_LENS_PROJECTS` | _(all)_ | Comma-separated project paths to monitor (`~` supported) |
package/cli/hooks.js CHANGED
@@ -145,7 +145,7 @@ export function cursorCaptureCommand(useTilde = false, customPath = null) {
145
145
  // Client file installation
146
146
  // ---------------------------------------------------------------------------
147
147
 
148
- const CLIENT_FILES = ['capture.js', 'sender.js', 'config.js', 'redact.js'];
148
+ const CLIENT_FILES = ['capture.js', 'sender.js', 'config.js', 'redact.js', 'codex.js', 'codex-watcher.js'];
149
149
 
150
150
  /**
151
151
  * Copy client/ files from the package source to ~/.ai-lens/client/.
package/cli/init.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createInterface } from 'node:readline';
2
2
  import { execSync, spawn } from 'node:child_process';
3
3
  import { existsSync, copyFileSync, readdirSync } from 'node:fs';
4
- import { join, resolve, relative } from 'node:path';
4
+ import { join, resolve, relative, dirname } from 'node:path';
5
5
  import { homedir } from 'node:os';
6
6
  import { request as httpRequest } from 'node:http';
7
7
  import { request as httpsRequest } from 'node:https';
@@ -146,6 +146,19 @@ function sleep(ms) {
146
146
  return new Promise(resolve => setTimeout(resolve, ms));
147
147
  }
148
148
 
149
+ function startCodexWatcher(watcherPath) {
150
+ if (!existsSync(watcherPath)) return false;
151
+ if (process.env.AI_LENS_TEST_NO_DETACHED_SPAWN === '1') return true;
152
+ const child = spawn(process.execPath, [watcherPath], {
153
+ detached: true,
154
+ stdio: 'ignore',
155
+ windowsHide: true,
156
+ });
157
+ child.on('error', () => {});
158
+ child.unref();
159
+ return true;
160
+ }
161
+
149
162
  async function deviceCodeAuth(serverUrl) {
150
163
  // 1. Fetch Auth0 config from server
151
164
  let config;
@@ -386,14 +399,12 @@ export default async function init() {
386
399
  }
387
400
 
388
401
  if (tools.length === 0) {
389
- warn('No supported AI tools detected.');
390
- info('Looked for ~/.claude/ and ~/.cursor/ directories.');
391
- info('Install Claude Code or Cursor, then re-run: npx -y ai-lens init');
392
- return;
393
- }
394
-
395
- for (const tool of tools) {
396
- success(` Found: ${tool.name} (${tool.dirPath})`);
402
+ warn('No Claude Code or Cursor installation detected.');
403
+ info('Continuing with AI Lens client install, config, and Codex watcher setup.');
404
+ } else {
405
+ for (const tool of tools) {
406
+ success(` Found: ${tool.name} (${tool.dirPath})`);
407
+ }
397
408
  }
398
409
 
399
410
  // Configuration
@@ -419,19 +430,20 @@ export default async function init() {
419
430
 
420
431
  // Project filter
421
432
  const currentProjects = currentConfig.projects || null;
433
+ const projectHooksDefault = flags.projectHooks ? resolve(process.cwd()) : null;
422
434
  let projects;
423
435
  if (flags.projects) {
424
436
  projects = flags.projects;
425
437
  } else if (auto) {
426
- projects = currentProjects;
438
+ projects = currentProjects || projectHooksDefault;
427
439
  } else {
428
- const projectsDefault = currentProjects || 'all';
440
+ const projectsDefault = currentProjects || projectHooksDefault || 'all';
429
441
  const projectsInput = await ask(
430
442
  `Projects to track (comma-separated, ~ supported, Enter = ${projectsDefault}): `,
431
443
  );
432
444
  projects = (projectsInput && projectsInput.trim() && projectsInput.trim().toLowerCase() !== 'all')
433
445
  ? projectsInput
434
- : null;
446
+ : (projectHooksDefault || null);
435
447
  }
436
448
  // Guard: non-string (e.g. array from corrupt config) → treat as unset
437
449
  if (projects && typeof projects !== 'string') {
@@ -530,8 +542,12 @@ export default async function init() {
530
542
  }
531
543
  }
532
544
 
533
- if (flags.noHooks) {
534
- info('--no-hooks: skipping hook configuration and MCP setup');
545
+ if (flags.noHooks || tools.length === 0) {
546
+ if (tools.length === 0 && !flags.noHooks) {
547
+ info('No Claude Code or Cursor hooks to configure in this environment.');
548
+ } else if (flags.noHooks) {
549
+ info('--no-hooks: skipping hook configuration and MCP setup');
550
+ }
535
551
  saveLensConfig(newConfig);
536
552
  } else {
537
553
  // Analyze each tool
@@ -727,6 +743,33 @@ export default async function init() {
727
743
  warn(` Migration skipped: ${err.message}`);
728
744
  }
729
745
 
746
+ heading('Codex');
747
+ let enableCodex = currentConfig.codexEnabled === true;
748
+ if (auto) {
749
+ enableCodex = true;
750
+ } else {
751
+ const defaultLabel = enableCodex ? 'Y/n' : 'y/N';
752
+ const answer = await ask(` Enable Codex session tracking? [${defaultLabel}] `);
753
+ if (answer) {
754
+ enableCodex = ['y', 'yes'].includes(answer.toLowerCase());
755
+ }
756
+ }
757
+ newConfig.codexEnabled = enableCodex;
758
+ saveLensConfig(newConfig);
759
+
760
+ if (enableCodex) {
761
+ const watcherPath = flags.useRepoPath
762
+ ? join(dirname(REPO_CAPTURE_PATH), 'codex-watcher.js')
763
+ : join(dirname(CAPTURE_PATH), 'codex-watcher.js');
764
+ if (startCodexWatcher(watcherPath)) {
765
+ success(' Codex watcher started');
766
+ } else {
767
+ warn(` Codex watcher not started: missing ${watcherPath}`);
768
+ }
769
+ } else {
770
+ info(' Codex tracking disabled');
771
+ }
772
+
730
773
  // Flush any pending events with the new config (e.g. backlog from previous 401/network errors)
731
774
  const senderPath = join(homedir(), '.ai-lens', 'client', 'sender.js');
732
775
  if (existsSync(senderPath)) {
package/cli/remove.js CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  buildStrippedConfig, writeHooksConfig, removeClientFiles, getVersionInfo,
13
13
  cleanupLegacyHooks, cleanupEmptyMcpJson, removeCursorMcp,
14
14
  } from './hooks.js';
15
+ import { stopCodexWatcher } from '../client/codex-watcher.js';
15
16
 
16
17
  function ask(question) {
17
18
  const rl = createInterface({ input: process.stdin, output: process.stdout });
@@ -121,6 +122,23 @@ export default async function remove() {
121
122
  }
122
123
  blank();
123
124
 
125
+ // Stop Codex watcher before deleting ~/.ai-lens files so the process does not
126
+ // keep running against removed paths or leave a stale lock behind.
127
+ heading('Stopping Codex watcher...');
128
+ try {
129
+ const watcherResult = await stopCodexWatcher();
130
+ if (watcherResult.previousState === 'active') {
131
+ success(` Codex watcher stopped (pid ${watcherResult.pid}${watcherResult.forced ? ', forced' : ''})`);
132
+ } else if (watcherResult.previousState === 'missing') {
133
+ info(' Codex watcher not running');
134
+ } else {
135
+ success(` Codex watcher lock cleaned up (${watcherResult.previousState})`);
136
+ }
137
+ } catch (err) {
138
+ error(` Failed to stop Codex watcher: ${err.message}`);
139
+ }
140
+ blank();
141
+
124
142
  // Remove MCP servers
125
143
  heading('Removing MCP servers...');
126
144
 
package/cli/status.js CHANGED
@@ -5,8 +5,9 @@ import { homedir, release as osRelease, arch as osArch } from 'node:os';
5
5
  import { randomUUID } from 'node:crypto';
6
6
 
7
7
  import { getVersionInfo, readLensConfig, detectInstalledTools, getCursorToolConfig, getClaudeCodeToolConfig, analyzeToolHooks, checkHooksDisabled, CAPTURE_PATH, TOOL_CONFIGS } from './hooks.js';
8
- import { DATA_DIR, PENDING_DIR, SENDING_DIR, SESSION_PATHS_DIR, LOG_PATH, CAPTURE_LOG_PATH, getGitIdentity } from '../client/config.js';
8
+ import { DATA_DIR, PENDING_DIR, SENDING_DIR, SESSION_PATHS_DIR, LOG_PATH, CAPTURE_LOG_PATH, getGitIdentity, getMonitoredProjects } from '../client/config.js';
9
9
  import { isLockStale } from '../client/sender.js';
10
+ import { readCodexWatcherLock, resolveWatchedCodexDirs } from '../client/codex-watcher.js';
10
11
  import { initLogger, info, success, warn, error, heading, blank } from './logger.js';
11
12
 
12
13
  const INIT_LOG_PATH = join(DATA_DIR, 'init.log');
@@ -223,14 +224,15 @@ function checkCaptureRun(installedTools) {
223
224
  });
224
225
  let shellOk = false;
225
226
  let shellDetail = '';
227
+ // Declared outside try: GUI PATH test below uses isCursor + shellCommand (must stay in scope).
228
+ const isWin = process.platform === 'win32';
229
+ const isCursor = name === 'Cursor' || name.startsWith('Cursor (');
230
+ const usePS = isWin && isCursor;
231
+ let shellCommand = !isWin && command.startsWith('& ') ? command.slice(2).trim() : command;
226
232
  try {
227
233
  // On Windows, Cursor runs hooks via PowerShell (needs `& "..."` call-operator),
228
234
  // but Claude Code runs hooks via bash/cmd (no `&` prefix).
229
235
  // On non-Windows, strip leading "& " if present (e.g. config copied from Windows) so bash doesn't background the process.
230
- const isWin = process.platform === 'win32';
231
- const isCursor = name === 'Cursor' || name.startsWith('Cursor (');
232
- const usePS = isWin && isCursor;
233
- let shellCommand = !isWin && command.startsWith('& ') ? command.slice(2).trim() : command;
234
236
  // Expand ~ in the command: bash/zsh won't expand ~ inside single quotes,
235
237
  // so replace '~/...' and bare ~/... with the absolute home path.
236
238
  if (!isWin) {
@@ -545,6 +547,51 @@ function checkQueue() {
545
547
  return { ok, summary, detail, lineCount: pendingCount, lockStatus };
546
548
  }
547
549
 
550
+ function pluralize(count, singular, plural = singular + 's') {
551
+ return `${count} ${count === 1 ? singular : plural}`;
552
+ }
553
+
554
+ export function checkCodexWatcher({
555
+ dataDir = DATA_DIR,
556
+ monitoredRoots = getMonitoredProjects(),
557
+ homeDir = homedir(),
558
+ userCodexDirs,
559
+ } = {}) {
560
+ const watchedDirs = resolveWatchedCodexDirs(monitoredRoots, userCodexDirs, homeDir);
561
+ const lock = readCodexWatcherLock(join(dataDir, 'codex-watcher.lock'));
562
+ const watchedSummary = pluralize(watchedDirs.length, 'watched dir');
563
+
564
+ if (lock.state === 'active') {
565
+ return {
566
+ ok: true,
567
+ summary: `active (pid ${lock.pid}, ${watchedSummary})`,
568
+ detail: `Lock: ${lock.lockPath}\nPID: ${lock.pid}\nWatched dirs:\n${watchedDirs.map(dir => `- ${dir}`).join('\n') || '(none discovered yet)'}`,
569
+ };
570
+ }
571
+
572
+ if (lock.state === 'stale') {
573
+ return {
574
+ ok: false,
575
+ summary: `stale lock (pid ${lock.pid}, ${watchedSummary})`,
576
+ detail: `Lock: ${lock.lockPath}\nStale PID: ${lock.pid}\nWatched dirs:\n${watchedDirs.map(dir => `- ${dir}`).join('\n') || '(none discovered yet)'}`,
577
+ };
578
+ }
579
+
580
+ if (lock.state === 'invalid' || lock.state === 'error') {
581
+ return {
582
+ ok: false,
583
+ summary: `lock ${lock.state} (${watchedSummary})`,
584
+ detail: `Lock: ${lock.lockPath}\nError: ${lock.error || '(none)'}\nWatched dirs:\n${watchedDirs.map(dir => `- ${dir}`).join('\n') || '(none discovered yet)'}`,
585
+ };
586
+ }
587
+
588
+ return {
589
+ ok: watchedDirs.length > 0 ? false : null,
590
+ summary: `not running (${watchedSummary})`,
591
+ detail: `Lock: ${lock.lockPath}\nWatched dirs:\n${watchedDirs.map(dir => `- ${dir}`).join('\n') || '(none discovered yet)'}`,
592
+ };
593
+ }
594
+
548
595
  function checkSenderLog() {
549
596
  if (!existsSync(LOG_PATH)) {
550
597
  return { ok: null, summary: 'no log file', detail: 'Sender log does not exist (sender has not run yet)' };
@@ -1062,6 +1109,9 @@ export default async function status() {
1062
1109
  const configResult = checkConfig();
1063
1110
  printLine('Config', configResult);
1064
1111
 
1112
+ // 5b. Codex watcher
1113
+ printLine('Codex watcher', checkCodexWatcher());
1114
+
1065
1115
  // 6. Hooks: global + project (Cursor then Claude Code; within each: global then project)
1066
1116
  const installedTools = detectInstalledTools();
1067
1117
  const toolsWithProject = getToolsForCaptureTest();
package/client/capture.js CHANGED
@@ -13,19 +13,24 @@ import { spawn } from 'node:child_process';
13
13
  import { fileURLToPath } from 'node:url';
14
14
  import { createHash } from 'node:crypto';
15
15
  import {
16
+ DATA_DIR,
16
17
  ensureDataDir,
17
18
  PENDING_DIR,
19
+ SENDING_DIR,
18
20
  DEDUP_DIR,
19
21
  SESSION_PATHS_DIR,
20
22
  CAPTURE_LOG_PATH,
21
23
  LOG_MAX_AGE_DAYS,
24
+ SENDER_BACKOFF_PATH,
22
25
  captureLog,
23
26
  getServerUrl,
24
27
  getAuthToken,
25
28
  getGitIdentity,
26
29
  getGitMetadata,
27
30
  getMonitoredProjects,
31
+ isCodexEnabled,
28
32
  } from './config.js';
33
+ import { isLockStale, isSenderBackoffActive } from './sender.js';
29
34
  // Soft import — redact.js may not exist on older client installs
30
35
  let redactObject = (o) => o;
31
36
  try {
@@ -619,7 +624,7 @@ export function normalizeEvent(event) {
619
624
  // Queue + Sender Spawn
620
625
  // =============================================================================
621
626
 
622
- function writeToSpool(unified) {
627
+ export function writeToSpool(unified) {
623
628
  // Shallow copy before redaction — do not mutate the caller's object
624
629
  const toWrite = { ...unified };
625
630
  // Redact secrets from individual string values (not serialized JSON)
@@ -645,7 +650,14 @@ function writeToSpool(unified) {
645
650
 
646
651
  // Always try to spawn sender — renameSync in sender acts as the real mutex.
647
652
  // If sender is already running, the new one exits immediately (ENOENT on rename).
648
- function trySpawnSender() {
653
+ export function shouldSpawnSender(lockPath = join(SENDING_DIR, '.sender.lock'), backoffPath = SENDER_BACKOFF_PATH, nowMs = Date.now()) {
654
+ if (isSenderBackoffActive(backoffPath, nowMs)) return false;
655
+ if (existsSync(lockPath) && !isLockStale(lockPath)) return false;
656
+ return true;
657
+ }
658
+
659
+ export function trySpawnSender() {
660
+ if (!shouldSpawnSender()) return;
649
661
  const senderPath = join(__dirname, 'sender.js');
650
662
  const child = spawn(process.execPath, [senderPath], {
651
663
  detached: true,
@@ -656,6 +668,35 @@ function trySpawnSender() {
656
668
  child.unref();
657
669
  }
658
670
 
671
+ function hasLivePidLock(lockPath) {
672
+ try {
673
+ const pid = Number.parseInt(readFileSync(lockPath, 'utf-8').trim().split(/\r?\n/)[0], 10);
674
+ if (!pid) return false;
675
+ process.kill(pid, 0);
676
+ return true;
677
+ } catch (err) {
678
+ return err?.code === 'EPERM';
679
+ }
680
+ }
681
+
682
+ export function shouldSpawnCodexWatcher(lockPath = join(DATA_DIR, 'codex-watcher.lock')) {
683
+ return !hasLivePidLock(lockPath);
684
+ }
685
+
686
+ function trySpawnCodexWatcher() {
687
+ if (!isCodexEnabled()) return;
688
+ if (!shouldSpawnCodexWatcher()) return;
689
+ const watcherPath = join(__dirname, 'codex-watcher.js');
690
+ if (!existsSync(watcherPath)) return;
691
+ const child = spawn(process.execPath, [watcherPath], {
692
+ detached: true,
693
+ stdio: 'ignore',
694
+ windowsHide: true,
695
+ });
696
+ child.on('error', () => {});
697
+ child.unref();
698
+ }
699
+
659
700
  // =============================================================================
660
701
  // Main
661
702
  // =============================================================================
@@ -770,6 +811,12 @@ async function main() {
770
811
  captureLog({ msg: 'sender-spawn-failed', error: err.message });
771
812
  // event is queued — sender will be spawned on next capture
772
813
  }
814
+
815
+ try {
816
+ trySpawnCodexWatcher();
817
+ } catch (err) {
818
+ captureLog({ msg: 'codex-watcher-spawn-failed', error: err.message });
819
+ }
773
820
  }
774
821
 
775
822
  // Only run main when executed directly (not when imported for testing).