ai-lens 0.8.55 → 0.8.59

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
- 93a1682
1
+ 8656b37
package/README.md CHANGED
@@ -136,7 +136,7 @@ Images are stored in ECR (`267996409571.dkr.ecr.eu-north-1.amazonaws.com/ai-lens
136
136
  | `PORT` | `3000` | Server port |
137
137
  | `DATABASE_URL` | _(required)_ | PostgreSQL connection string |
138
138
  | `POSTGRES_PASSWORD` | `ailens` | PostgreSQL password (docker compose) |
139
- | `ANALYSIS_INTERVAL` | `3600` | Seconds between analysis runs |
139
+ | `ANALYSIS_INTERVAL` | `60` | Seconds between analysis runs |
140
140
  | `AI_LENS_ADMIN_SECRET` | _(none)_ | Admin secret for auth token management |
141
141
  | `OPENAI_API_KEY` | _(none)_ | OpenAI API key for FAISS vector search; text search works without it |
142
142
  | `TEAMS_CONFIG` | _(none)_ | JSON config for team definitions |
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, dirname } from 'node:path';
4
+ import { join, resolve, relative } 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';
@@ -21,6 +21,7 @@ import {
21
21
  cleanupLegacyHooks, cleanupOppositeScope, cleanupEmptyMcpJson, addCursorMcp, removeCursorMcp,
22
22
  checkHooksDisabled, enableHooks,
23
23
  } from './hooks.js';
24
+ import { scanNestedClaudeProjects, summarizeNestedProjects } from './scan.js';
24
25
 
25
26
  function ask(question) {
26
27
  const rl = createInterface({ input: process.stdin, output: process.stdout });
@@ -167,17 +168,17 @@ async function withRetries(fn, { attempts = 5, baseDelayMs = 2000 } = {}) {
167
168
  throw lastErr;
168
169
  }
169
170
 
170
- function startCodexWatcher(watcherPath) {
171
- if (!existsSync(watcherPath)) return false;
172
- if (process.env.AI_LENS_TEST_NO_DETACHED_SPAWN === '1') return true;
173
- const child = spawn(process.execPath, [watcherPath], {
174
- detached: true,
175
- stdio: 'ignore',
176
- windowsHide: true,
177
- });
178
- child.on('error', () => {});
179
- child.unref();
180
- return true;
171
+ function getTrackedRoots(projects, fallbackRoot) {
172
+ if (!projects || typeof projects !== 'string') return [fallbackRoot];
173
+ return projects.split(',').map(path => path.trim()).filter(Boolean);
174
+ }
175
+
176
+ function makeNestedClaudeTool(projectDir, capturePathInHooks) {
177
+ const tool = getClaudeCodeToolConfig(projectDir, `Claude Code (${projectDir})`);
178
+ if (capturePathInHooks) {
179
+ tool.hookDefs = getClaudeCodeHookDefsWithPath(capturePathInHooks);
180
+ }
181
+ return tool;
181
182
  }
182
183
 
183
184
  async function deviceCodeAuth(serverUrl) {
@@ -608,7 +609,49 @@ export default async function init() {
608
609
  // Filter to tools that need changes
609
610
  const pending = analyses.filter(a => a.analysis.status !== 'current');
610
611
 
611
- if (pending.length === 0) {
612
+ let nestedScan = [];
613
+ let nestedPending = [];
614
+ if (flags.projectHooks) {
615
+ const trackedRoots = getTrackedRoots(newConfig.projects, resolve(process.cwd()));
616
+ nestedScan = scanNestedClaudeProjects(trackedRoots);
617
+
618
+ if (nestedScan.length > 0) {
619
+ heading('Nested Claude Code projects');
620
+ for (const result of nestedScan) {
621
+ const installNote = result.installTarget ? ` -> install in ${result.installTarget}` : '';
622
+ info(` ${result.relativePath}: ${result.status}${installNote}`);
623
+ }
624
+ const summary = summarizeNestedProjects(nestedScan);
625
+ detail(`Found ${summary.total} nested project(s), ${summary.unhooked} need attention.`);
626
+ blank();
627
+ }
628
+
629
+ const nestedActionable = nestedScan.filter(result => result.installTarget);
630
+ let shouldInstallNested = auto && nestedActionable.length > 0;
631
+ if (!auto && nestedActionable.length > 0) {
632
+ const answer = await ask(`Install hooks in ${nestedActionable.length} nested Claude Code project(s)? [Y/n] `);
633
+ shouldInstallNested = !answer || ['y', 'yes'].includes(answer.toLowerCase());
634
+ }
635
+
636
+ if (shouldInstallNested) {
637
+ nestedPending = nestedActionable.map(result => {
638
+ const capturePathInHooks = flags.useRepoPath
639
+ ? relative(result.projectDir, resolve(REPO_CAPTURE_PATH)).replace(/\\/g, '/')
640
+ : null;
641
+ const tool = makeNestedClaudeTool(result.projectDir, capturePathInHooks);
642
+ tool.configPath = join(tool.dirPath, result.installTarget);
643
+ return {
644
+ tool,
645
+ analysis: {
646
+ status: result.status === 'malformed' ? 'malformed' : 'absent',
647
+ config: result.existingConfig || null,
648
+ },
649
+ };
650
+ });
651
+ }
652
+ }
653
+
654
+ if (pending.length === 0 && nestedPending.length === 0) {
612
655
  saveLensConfig(newConfig);
613
656
 
614
657
  // Clean up legacy hook locations (safe: hooks are already current)
@@ -640,6 +683,9 @@ export default async function init() {
640
683
  if (!plan) continue;
641
684
  info(` ${plan.description}`);
642
685
  }
686
+ for (const { tool } of nestedPending) {
687
+ info(` Configure ${tool.name} (${tool.configPath.endsWith('settings.local.json') ? 'write local hooks' : 'merge shared hooks'})`);
688
+ }
643
689
  blank();
644
690
 
645
691
  // Confirm
@@ -655,7 +701,7 @@ export default async function init() {
655
701
  heading('Applying changes...');
656
702
  const results = [];
657
703
 
658
- for (const { tool, analysis } of pending) {
704
+ for (const { tool, analysis } of [...pending, ...nestedPending]) {
659
705
  try {
660
706
  // Backup malformed shared configs (e.g. ~/.claude/settings.json) before overwriting
661
707
  if (analysis.status === 'malformed' && tool.sharedConfig) {
@@ -679,7 +725,7 @@ export default async function init() {
679
725
  // Verify hooks were written correctly
680
726
  heading('Verifying hooks...');
681
727
  let verifyFailed = false;
682
- for (const { tool } of pending) {
728
+ for (const { tool } of [...pending, ...nestedPending]) {
683
729
  const recheck = analyzeToolHooks(tool);
684
730
  if (recheck.status === 'current') {
685
731
  success(` ${tool.name}: hooks verified`);
@@ -787,18 +833,23 @@ export default async function init() {
787
833
  saveLensConfig(newConfig);
788
834
 
789
835
  if (enableCodex) {
790
- // Stop existing watcher so the new one picks up fresh config (projects, etc.)
791
- await stopCodexWatcher();
792
- const watcherPath = flags.useRepoPath
793
- ? join(dirname(REPO_CAPTURE_PATH), 'codex-watcher.js')
794
- : join(dirname(CAPTURE_PATH), 'codex-watcher.js');
795
- if (startCodexWatcher(watcherPath)) {
796
- success(' Codex watcher restarted');
836
+ if (flags.noHooks) {
837
+ warn(' Codex tracking enabled, but --no-hooks was used. Automatic watcher start requires existing Claude/Cursor hooks.');
838
+ } else if (tools.length === 0) {
839
+ warn(' Codex tracking enabled, but no Claude/Cursor installation was detected. Automatic watcher start is unavailable.');
797
840
  } else {
798
- warn(` Codex watcher not started: missing ${watcherPath}`);
841
+ info(' Codex watcher will be started by Claude/Cursor hooks on SessionStart');
799
842
  }
800
843
  } else {
801
844
  info(' Codex tracking disabled');
845
+ try {
846
+ const watcherResult = await stopCodexWatcher();
847
+ if (watcherResult.stopped) {
848
+ info(` Stopped existing Codex watcher process (pid ${watcherResult.pid})`);
849
+ }
850
+ } catch (err) {
851
+ warn(` Could not stop Codex watcher process: ${err.message}`);
852
+ }
802
853
  }
803
854
 
804
855
  // Flush any pending events with the new config (e.g. backlog from previous 401/network errors)
package/cli/scan.js ADDED
@@ -0,0 +1,184 @@
1
+ import { existsSync, readdirSync, readFileSync, lstatSync } from 'node:fs';
2
+ import { join, relative, resolve } from 'node:path';
3
+
4
+ import { isAiLensHook } from './hooks.js';
5
+
6
+ const DEFAULT_MAX_DEPTH = 6;
7
+ const SKIP_DIRS = new Set(['.git', '.svn', '.hg', 'node_modules', '.venv', 'dist']);
8
+
9
+ function readJsonSafe(path) {
10
+ if (!existsSync(path)) return null;
11
+ try {
12
+ return JSON.parse(readFileSync(path, 'utf-8'));
13
+ } catch {
14
+ return 'MALFORMED';
15
+ }
16
+ }
17
+
18
+ function loadGitignoreRules(dirPath) {
19
+ const gitignorePath = join(dirPath, '.gitignore');
20
+ if (!existsSync(gitignorePath)) return [];
21
+ try {
22
+ return readFileSync(gitignorePath, 'utf-8')
23
+ .split(/\r?\n/)
24
+ .map(line => line.trim())
25
+ // Note: negation patterns (!pattern) and glob wildcards (*, **, ?) are not supported.
26
+ // This is a simplified implementation that handles literal directory names and paths.
27
+ .filter(line => line && !line.startsWith('#') && !line.startsWith('!'))
28
+ .map(pattern => ({
29
+ baseDir: dirPath,
30
+ pattern: pattern.replace(/\\/g, '/').replace(/\/+$/, ''),
31
+ directoryOnly: pattern.endsWith('/'),
32
+ }))
33
+ .filter(rule => rule.pattern);
34
+ } catch {
35
+ return [];
36
+ }
37
+ }
38
+
39
+ function matchesGitignoreRule(rule, targetPath) {
40
+ const rel = relative(rule.baseDir, targetPath).replace(/\\/g, '/');
41
+ if (!rel || rel.startsWith('..')) return false;
42
+
43
+ if (!rule.pattern.includes('/')) {
44
+ const segments = rel.split('/').filter(Boolean);
45
+ return segments.some(segment => segment === rule.pattern);
46
+ }
47
+
48
+ return rel === rule.pattern || rel.startsWith(rule.pattern + '/');
49
+ }
50
+
51
+ function isIgnoredByGitignore(rules, targetPath) {
52
+ return rules.some(rule => matchesGitignoreRule(rule, targetPath));
53
+ }
54
+
55
+ function countDepth(rootPath, targetPath) {
56
+ const rel = relative(rootPath, targetPath);
57
+ if (!rel) return 0;
58
+ return rel.split(/[\\/]/).filter(Boolean).length;
59
+ }
60
+
61
+ function collectHookEntries(config) {
62
+ if (!config || config === 'MALFORMED' || typeof config !== 'object') return [];
63
+ const hooks = config.hooks;
64
+ if (!hooks || typeof hooks !== 'object') return [];
65
+ const entries = [];
66
+ for (const values of Object.values(hooks)) {
67
+ if (!Array.isArray(values)) continue;
68
+ entries.push(...values);
69
+ }
70
+ return entries;
71
+ }
72
+
73
+ function hasAnyAiLensHook(config) {
74
+ return collectHookEntries(config).some(isAiLensHook);
75
+ }
76
+
77
+ function hasAnyNonAiLensHooks(config) {
78
+ return collectHookEntries(config).some(entry => !isAiLensHook(entry));
79
+ }
80
+
81
+ function classifyProject(projectDir) {
82
+ const claudeDir = join(projectDir, '.claude');
83
+ const settingsPath = join(claudeDir, 'settings.json');
84
+ const settingsLocalPath = join(claudeDir, 'settings.local.json');
85
+ const settings = readJsonSafe(settingsPath);
86
+ const settingsLocal = readJsonSafe(settingsLocalPath);
87
+
88
+ if (settings === 'MALFORMED' || settingsLocal === 'MALFORMED') {
89
+ return {
90
+ status: 'malformed',
91
+ installTarget: null,
92
+ existingConfig: null,
93
+ };
94
+ }
95
+
96
+ if (hasAnyAiLensHook(settings) || hasAnyAiLensHook(settingsLocal)) {
97
+ return {
98
+ status: 'installed',
99
+ installTarget: null,
100
+ existingConfig: null,
101
+ };
102
+ }
103
+
104
+ if (hasAnyNonAiLensHooks(settings)) {
105
+ return {
106
+ status: 'has non-ai-lens hooks',
107
+ installTarget: 'settings.json',
108
+ existingConfig: settings,
109
+ };
110
+ }
111
+
112
+ return {
113
+ status: 'missing',
114
+ installTarget: 'settings.local.json',
115
+ existingConfig: settingsLocal,
116
+ };
117
+ }
118
+
119
+ function scanRoot(rootPath, options, results, seen) {
120
+ const { maxDepth } = options;
121
+
122
+ function visit(currentDir, inheritedRules = []) {
123
+ const rules = inheritedRules.concat(loadGitignoreRules(currentDir));
124
+ let children;
125
+ try {
126
+ children = readdirSync(currentDir, { withFileTypes: true });
127
+ } catch {
128
+ return;
129
+ }
130
+
131
+ for (const child of children) {
132
+ if (!child.isDirectory()) continue;
133
+ // Skip symlinks to avoid infinite loops in circular symlink structures
134
+ if (child.isSymbolicLink()) continue;
135
+ const childPath = join(currentDir, child.name);
136
+ if (isIgnoredByGitignore(rules, childPath)) continue;
137
+
138
+ if (child.name === '.git' || child.name === '.claude') {
139
+ const projectDir = resolve(currentDir);
140
+ if (projectDir !== rootPath) {
141
+ const depth = countDepth(rootPath, projectDir);
142
+ if (depth <= maxDepth && !seen.has(projectDir)) {
143
+ seen.add(projectDir);
144
+ results.push({
145
+ projectDir,
146
+ relativePath: relative(rootPath, projectDir).replace(/\\/g, '/'),
147
+ marker: child.name,
148
+ ...classifyProject(projectDir),
149
+ });
150
+ }
151
+ }
152
+ continue;
153
+ }
154
+
155
+ const depth = countDepth(rootPath, childPath);
156
+ if (depth > maxDepth) continue;
157
+ if (SKIP_DIRS.has(child.name)) continue;
158
+ visit(childPath, rules);
159
+ }
160
+ }
161
+
162
+ visit(rootPath);
163
+ }
164
+
165
+ export function scanNestedClaudeProjects(projectRoots, options = {}) {
166
+ const maxDepth = Number.isInteger(options.maxDepth) ? options.maxDepth : DEFAULT_MAX_DEPTH;
167
+ const roots = (projectRoots || []).map(path => resolve(path));
168
+ const results = [];
169
+ const seen = new Set();
170
+
171
+ for (const rootPath of roots) {
172
+ if (!existsSync(rootPath)) continue;
173
+ scanRoot(rootPath, { maxDepth }, results, seen);
174
+ }
175
+
176
+ return results.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
177
+ }
178
+
179
+ export function summarizeNestedProjects(results) {
180
+ const total = results.length;
181
+ const actionable = results.filter(result => result.installTarget).length;
182
+ const unhooked = results.filter(result => result.status !== 'installed').length;
183
+ return { total, actionable, unhooked };
184
+ }
package/cli/status.js CHANGED
@@ -9,6 +9,7 @@ import { DATA_DIR, PENDING_DIR, SENDING_DIR, SESSION_PATHS_DIR, LOG_PATH, CAPTUR
9
9
  import { isLockStale } from '../client/sender.js';
10
10
  import { readCodexWatcherLock, resolveWatchedCodexDirs } from '../client/codex-watcher.js';
11
11
  import { initLogger, info, success, warn, error, heading, blank } from './logger.js';
12
+ import { scanNestedClaudeProjects, summarizeNestedProjects } from './scan.js';
12
13
 
13
14
  const INIT_LOG_PATH = join(DATA_DIR, 'init.log');
14
15
 
@@ -596,35 +597,37 @@ export function checkCodexWatcher({
596
597
  const watchedDirs = resolveWatchedCodexDirs(monitoredRoots, userCodexDirs, homeDir);
597
598
  const lock = readCodexWatcherLock(join(dataDir, 'codex-watcher.lock'));
598
599
  const watchedSummary = pluralize(watchedDirs.length, 'watched dir');
600
+ const lockActive = lock.state === 'active';
601
+ const trackingLine = `Tracking active: ${lockActive ? 'yes' : 'no'} (hook-launched watcher)`;
599
602
 
600
- if (lock.state === 'active') {
603
+ if (lockActive) {
601
604
  return {
602
605
  ok: true,
603
- summary: `active (pid ${lock.pid}, ${watchedSummary})`,
604
- detail: `Lock: ${lock.lockPath}\nPID: ${lock.pid}\nWatched dirs:\n${watchedDirs.map(dir => `- ${dir}`).join('\n') || '(none discovered yet)'}`,
606
+ summary: `tracking active (pid ${lock.pid}, ${watchedSummary})`,
607
+ detail: `${trackingLine}\nLock: ${lock.lockPath}\nPID: ${lock.pid}\nWatched dirs:\n${watchedDirs.map(dir => `- ${dir}`).join('\n') || '(none discovered yet)'}`,
605
608
  };
606
609
  }
607
610
 
608
611
  if (lock.state === 'stale') {
609
612
  return {
610
613
  ok: false,
611
- summary: `stale lock (pid ${lock.pid}, ${watchedSummary})`,
612
- detail: `Lock: ${lock.lockPath}\nStale PID: ${lock.pid}\nWatched dirs:\n${watchedDirs.map(dir => `- ${dir}`).join('\n') || '(none discovered yet)'}`,
614
+ summary: `tracking inactive (stale lock pid ${lock.pid}, ${watchedSummary})`,
615
+ detail: `${trackingLine}\nLock: ${lock.lockPath}\nStale PID: ${lock.pid}\nWatched dirs:\n${watchedDirs.map(dir => `- ${dir}`).join('\n') || '(none discovered yet)'}`,
613
616
  };
614
617
  }
615
618
 
616
619
  if (lock.state === 'invalid' || lock.state === 'error') {
617
620
  return {
618
621
  ok: false,
619
- summary: `lock ${lock.state} (${watchedSummary})`,
620
- detail: `Lock: ${lock.lockPath}\nError: ${lock.error || '(none)'}\nWatched dirs:\n${watchedDirs.map(dir => `- ${dir}`).join('\n') || '(none discovered yet)'}`,
622
+ summary: `tracking inactive (lock ${lock.state}, ${watchedSummary})`,
623
+ detail: `${trackingLine}\nLock: ${lock.lockPath}\nError: ${lock.error || '(none)'}\nWatched dirs:\n${watchedDirs.map(dir => `- ${dir}`).join('\n') || '(none discovered yet)'}`,
621
624
  };
622
625
  }
623
626
 
624
627
  return {
625
628
  ok: watchedDirs.length > 0 ? false : null,
626
- summary: `not running (${watchedSummary})`,
627
- detail: `Lock: ${lock.lockPath}\nWatched dirs:\n${watchedDirs.map(dir => `- ${dir}`).join('\n') || '(none discovered yet)'}`,
629
+ summary: `tracking inactive (${watchedSummary})`,
630
+ detail: `${trackingLine}\nLock: ${lock.lockPath}\nWatched dirs:\n${watchedDirs.map(dir => `- ${dir}`).join('\n') || '(none discovered yet)'}`,
628
631
  };
629
632
  }
630
633
 
@@ -1235,6 +1238,24 @@ export default async function status({ report = false } = {}) {
1235
1238
  const installMode = detectInstallMode(toolsWithProject);
1236
1239
  printLine('Install mode', installMode);
1237
1240
 
1241
+ if (hasProjectHooks) {
1242
+ const nestedRoots = getMonitoredProjects() || [process.cwd()];
1243
+ const nestedProjects = scanNestedClaudeProjects(nestedRoots);
1244
+ const nestedSummary = summarizeNestedProjects(nestedProjects);
1245
+ const nestedUnhooked = nestedProjects.filter(result => result.status !== 'installed');
1246
+ printLine('Nested projects', {
1247
+ ok: nestedSummary.unhooked === 0,
1248
+ summary: nestedSummary.total === 0
1249
+ ? 'none found'
1250
+ : nestedSummary.unhooked === 0
1251
+ ? `${nestedSummary.total} nested project(s), all hooked`
1252
+ : `${nestedSummary.unhooked} unhooked / ${nestedSummary.total} nested project(s)`,
1253
+ detail: nestedUnhooked.length > 0
1254
+ ? `nested_unhooked_projects:\n${nestedUnhooked.map(result => `- ${result.projectDir} (${result.status})`).join('\n')}`
1255
+ : 'nested_unhooked_projects: none',
1256
+ });
1257
+ }
1258
+
1238
1259
  // 7. Queue (before capture test so test event doesn't show as pending)
1239
1260
  const queueResult = checkQueue();
1240
1261
  printLine('Queue', queueResult);
package/client/capture.js CHANGED
@@ -1028,15 +1028,25 @@ export function shouldSpawnCodexWatcher(lockPath = join(DATA_DIR, 'codex-watcher
1028
1028
  return !hasLivePidLock(lockPath);
1029
1029
  }
1030
1030
 
1031
- function trySpawnCodexWatcher() {
1031
+ export function shouldReplayCodexHistory(unified) {
1032
+ if (!unified) return false;
1033
+ if (unified.type !== 'SessionStart') return false;
1034
+ return unified.source === 'claude_code' || unified.source === 'cursor';
1035
+ }
1036
+
1037
+ function trySpawnCodexWatcher({ replayExisting = false } = {}) {
1032
1038
  if (!isCodexEnabled()) return;
1033
1039
  if (!shouldSpawnCodexWatcher()) return;
1034
1040
  const watcherPath = join(__dirname, 'codex-watcher.js');
1035
1041
  if (!existsSync(watcherPath)) return;
1042
+ const env = replayExisting
1043
+ ? { ...process.env, AI_LENS_CODEX_REPLAY_EXISTING: '1' }
1044
+ : process.env;
1036
1045
  const child = spawn(process.execPath, [watcherPath], {
1037
1046
  detached: true,
1038
1047
  stdio: 'ignore',
1039
1048
  windowsHide: true,
1049
+ env,
1040
1050
  });
1041
1051
  child.on('error', () => {});
1042
1052
  child.unref();
@@ -1224,7 +1234,7 @@ async function main() {
1224
1234
  }
1225
1235
 
1226
1236
  try {
1227
- trySpawnCodexWatcher();
1237
+ trySpawnCodexWatcher({ replayExisting: shouldReplayCodexHistory(unified) });
1228
1238
  } catch (err) {
1229
1239
  captureLog({ msg: 'codex-watcher-spawn-failed', error: err.message });
1230
1240
  }
@@ -33,6 +33,8 @@ const POLL_MS = 2000;
33
33
  const DISCOVERY_MS = 30_000;
34
34
  const STOP_IDLE_TIMEOUT_MS = 15_000;
35
35
  const EOF_IDLE_TIMEOUT_MS = 300_000;
36
+ const REPLAY_DAYS = Math.max(1, Number.parseInt(process.env.AI_LENS_CODEX_REPLAY_DAYS || '30', 10) || 30);
37
+ const REPLAY_WINDOW_MS = REPLAY_DAYS * 24 * 60 * 60 * 1000;
36
38
  const MAX_SCAN_DEPTH = 8;
37
39
  const IGNORED_DIR_NAMES = new Set([
38
40
  '.git',
@@ -368,18 +370,28 @@ export function buildSyntheticSubagentStop(fileState, sessionEndEvent) {
368
370
  }
369
371
 
370
372
  function createRuntimeState() {
373
+ const nowMs = Date.now();
371
374
  return {
372
375
  tracker: createCodexTrackerState(),
373
376
  codexDirs: [],
374
377
  lastDiscoveryAt: 0,
375
378
  firstScanCompleted: false,
376
379
  replayExisting: process.env.AI_LENS_CODEX_REPLAY_EXISTING === '1',
380
+ replayCutoffMs: nowMs - REPLAY_WINDOW_MS,
377
381
  historyFiles: new Map(),
378
382
  sessionFiles: new Map(),
379
383
  pendingHistory: new Map(),
380
384
  };
381
385
  }
382
386
 
387
+ export function isWithinReplayWindow(runtimeState, unified) {
388
+ if (!runtimeState.replayExisting || runtimeState.firstScanCompleted) return true;
389
+ if (!unified?.timestamp) return false;
390
+ const ts = Date.parse(unified.timestamp);
391
+ if (!Number.isFinite(ts)) return false;
392
+ return ts >= runtimeState.replayCutoffMs;
393
+ }
394
+
383
395
  function refreshCodexDirs(runtimeState) {
384
396
  const now = Date.now();
385
397
  if (runtimeState.codexDirs.length > 0 && now - runtimeState.lastDiscoveryAt < DISCOVERY_MS) {
@@ -403,7 +415,9 @@ function flushPendingHistory(runtimeState) {
403
415
  if (!projectPath) continue;
404
416
  for (const { entry, rawLine } of items) {
405
417
  const unified = normalizeCodexHistoryEntry(entry, runtimeState.tracker);
406
- ingestUnifiedEvent(unified, rawLine);
418
+ if (isWithinReplayWindow(runtimeState, unified)) {
419
+ ingestUnifiedEvent(unified, rawLine);
420
+ }
407
421
  }
408
422
  runtimeState.pendingHistory.delete(sessionId);
409
423
  }
@@ -421,7 +435,9 @@ function processHistory(runtimeState) {
421
435
  const entry = JSON.parse(line);
422
436
  const unified = normalizeCodexHistoryEntry(entry, runtimeState.tracker);
423
437
  if (unified) {
424
- ingestUnifiedEvent(unified, line);
438
+ if (isWithinReplayWindow(runtimeState, unified)) {
439
+ ingestUnifiedEvent(unified, line);
440
+ }
425
441
  } else if (entry?.session_id) {
426
442
  queuePendingHistory(runtimeState, entry.session_id, entry, line);
427
443
  }
@@ -446,7 +462,9 @@ function processSessionFiles(runtimeState) {
446
462
  const events = normalizeCodexSessionEntries(entry, runtimeState.tracker, filePath);
447
463
  events.forEach((unified, index) => {
448
464
  trackSessionFileEvent(current, unified, Date.now());
449
- ingestUnifiedEvent(unified, line, `${index}:${unified.type}`);
465
+ if (isWithinReplayWindow(runtimeState, unified)) {
466
+ ingestUnifiedEvent(unified, line, `${index}:${unified.type}`);
467
+ }
450
468
  });
451
469
  } catch (err) {
452
470
  captureLog({ msg: 'codex-session-parse-failed', filePath, error: err.message });
@@ -461,6 +479,7 @@ function processSyntheticSessionEnds(runtimeState, nowMs = Date.now()) {
461
479
  for (const [filePath, fileState] of runtimeState.sessionFiles.entries()) {
462
480
  const sessionEnd = buildSyntheticSessionEnd(fileState, nowMs);
463
481
  if (!sessionEnd) continue;
482
+ if (!isWithinReplayWindow(runtimeState, sessionEnd)) continue;
464
483
  ingestUnifiedEvent(
465
484
  sessionEnd,
466
485
  JSON.stringify({ filePath, session_id: sessionEnd.session_id, type: sessionEnd.type, timestamp: sessionEnd.timestamp, reason: sessionEnd.data.reason }),
package/client/sender.js CHANGED
@@ -61,10 +61,15 @@ export function rotateLog(logPath = LOG_PATH, maxAgeDays = LOG_MAX_AGE_DAYS) {
61
61
  }
62
62
 
63
63
  export const MAX_QUEUE_SIZE = 10_000;
64
- export const MAX_CHUNK_BYTES = 50 * 1024; // 50 KB per POST — small chunks avoid ECONNRESET on corporate proxies/TLS inspection
64
+ export const MAX_CHUNK_BYTES = 10 * 1024; // 10 KB per POST — small enough to retry cheaply on flaky networks
65
65
  export const LOCK_MAX_AGE_MS = 5 * 60 * 1000; // 5 minutes
66
66
  export const SENDER_BACKOFF_MS = 30_000;
67
67
 
68
+ // Retry budget for postEvents() on transient transport errors.
69
+ // Invariant: POST_RETRY_BACKOFF_MS.length === POST_MAX_ATTEMPTS - 1
70
+ export const POST_MAX_ATTEMPTS = 4; // 1 initial + 3 retries
71
+ export const POST_RETRY_BACKOFF_MS = [500, 1500, 3500]; // sleeps BEFORE attempts 2, 3, 4
72
+
68
73
  // =============================================================================
69
74
  // Lock helpers (unchanged from v0)
70
75
  // =============================================================================
@@ -607,13 +612,45 @@ export function filterOversized(batch, maxBytes = MAX_CHUNK_BYTES) {
607
612
  // HTTP
608
613
  // =============================================================================
609
614
 
610
- async function postEvents(serverUrl, events, identity, authToken) {
615
+ // Codes that indicate a transient transport failure worth retrying.
616
+ // Covers Node's classic socket errors and undici-specific error codes.
617
+ const TRANSIENT_ERROR_CODES = new Set([
618
+ 'ECONNRESET',
619
+ 'ECONNREFUSED',
620
+ 'ETIMEDOUT',
621
+ 'EPIPE',
622
+ 'ENOTFOUND',
623
+ 'EAI_AGAIN',
624
+ 'UND_ERR_CONNECT_TIMEOUT',
625
+ 'UND_ERR_SOCKET',
626
+ 'UND_ERR_HEADERS_TIMEOUT',
627
+ 'UND_ERR_BODY_TIMEOUT',
628
+ ]);
629
+
630
+ /**
631
+ * Classify a fetch() error as transient (retry-worthy) vs permanent.
632
+ * Transient: socket/DNS hiccups, AbortSignal.timeout aborts, bare `fetch failed`.
633
+ * Permanent: HTTP 4xx/5xx (our own `Server responded` throw), invalid JSON body
634
+ * from a successful HTTP response, and anything we don't recognise.
635
+ */
636
+ export function isTransientFetchError(err) {
637
+ if (!err) return false;
638
+ const cause = err.cause;
639
+ if (cause && TRANSIENT_ERROR_CODES.has(cause.code)) return true;
640
+ if (err.name === 'TimeoutError') return true;
641
+ if (typeof err.message === 'string' && err.message.includes('aborted due to timeout')) return true;
642
+ if (err.message === 'fetch failed' && !cause) return true; // generic undici without details — be conservative
643
+ return false;
644
+ }
645
+
646
+ async function postEventsOnce(serverUrl, events, identity, authToken) {
611
647
  const body = JSON.stringify(events);
612
648
  const url = `${serverUrl}/api/events`;
613
649
 
614
650
  const { version: clientVersion, commit: clientCommit } = getClientVersion();
615
651
  const headers = {
616
652
  'Content-Type': 'application/json',
653
+ 'Connection': 'close',
617
654
  'X-Client-Version': `${clientVersion}+${clientCommit}`,
618
655
  };
619
656
  if (identity.email) headers['X-Developer-Git-Email'] = identity.email;
@@ -635,6 +672,43 @@ async function postEvents(serverUrl, events, identity, authToken) {
635
672
  throw new Error(`Server responded ${res.status}: ${data}`);
636
673
  }
637
674
 
675
+ /**
676
+ * POST events to the server with retries on transient transport errors.
677
+ * Retries up to POST_MAX_ATTEMPTS - 1 times with POST_RETRY_BACKOFF_MS delays.
678
+ * HTTP errors (4xx/5xx) and JSON-parse errors are NOT retried.
679
+ *
680
+ * @param {object} opts
681
+ * @param {string} [opts.lockPath] — if provided, refreshLock() is called between
682
+ * retry attempts so the sender lock doesn't go stale during long retry chains.
683
+ */
684
+ export async function postEvents(serverUrl, events, identity, authToken, opts = {}) {
685
+ const { lockPath } = opts;
686
+ let lastErr;
687
+ for (let attempt = 0; attempt < POST_MAX_ATTEMPTS; attempt++) {
688
+ if (attempt > 0) {
689
+ await new Promise(r => setTimeout(r, POST_RETRY_BACKOFF_MS[attempt - 1]));
690
+ if (lockPath) refreshLock(lockPath);
691
+ }
692
+ try {
693
+ return await postEventsOnce(serverUrl, events, identity, authToken);
694
+ } catch (err) {
695
+ lastErr = err;
696
+ if (!isTransientFetchError(err)) throw err;
697
+ const isLastAttempt = attempt === POST_MAX_ATTEMPTS - 1;
698
+ if (isLastAttempt) break; // don't log a retry that will never happen
699
+ log({
700
+ msg: 'post-retry',
701
+ attempt: attempt + 1,
702
+ of: POST_MAX_ATTEMPTS,
703
+ error: err.message,
704
+ code: err.cause?.code,
705
+ events: events.length,
706
+ });
707
+ }
708
+ }
709
+ throw lastErr;
710
+ }
711
+
638
712
  // =============================================================================
639
713
  // Status report (once per 24h)
640
714
  // =============================================================================
@@ -728,7 +802,7 @@ async function main() {
728
802
  const chunks = chunkEvents(sendable);
729
803
  let totalReceived = 0;
730
804
  for (const chunk of chunks) {
731
- const result = await postEvents(serverUrl, chunk, identity, hasAuthToken ? authToken : null);
805
+ const result = await postEvents(serverUrl, chunk, identity, hasAuthToken ? authToken : null, { lockPath });
732
806
  refreshLock(lockPath);
733
807
  totalReceived += (result?.received ?? 0);
734
808
  if ((result?.skipped ?? 0) > 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-lens",
3
- "version": "0.8.55",
3
+ "version": "0.8.59",
4
4
  "type": "module",
5
5
  "description": "Centralized session analytics for AI coding tools",
6
6
  "bin": {