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 +1 -1
- package/README.md +1 -1
- package/cli/init.js +74 -23
- package/cli/scan.js +184 -0
- package/cli/status.js +30 -9
- package/client/capture.js +12 -2
- package/client/codex-watcher.js +22 -3
- package/client/sender.js +77 -3
- package/package.json +1 -1
package/.commithash
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
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` | `
|
|
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
|
|
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
|
|
171
|
-
if (!
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
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
|
-
|
|
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 (
|
|
603
|
+
if (lockActive) {
|
|
601
604
|
return {
|
|
602
605
|
ok: true,
|
|
603
|
-
summary: `active (pid ${lock.pid}, ${watchedSummary})`,
|
|
604
|
-
detail:
|
|
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
|
|
612
|
-
detail:
|
|
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}
|
|
620
|
-
detail:
|
|
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: `
|
|
627
|
-
detail:
|
|
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
|
|
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
|
}
|
package/client/codex-watcher.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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) {
|