ai-lens 0.7.3 → 0.7.4

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
- 75f2d5f
1
+ 8b0424f
package/cli/hooks.js CHANGED
@@ -44,11 +44,11 @@ export function readLensConfig() {
44
44
 
45
45
  export function saveLensConfig(config) {
46
46
  mkdirSync(dirname(CONFIG_PATH), { recursive: true });
47
- writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + '\n');
47
+ const tmpPath = CONFIG_PATH + '.tmp.' + process.pid;
48
+ writeFileSync(tmpPath, JSON.stringify(config, null, 2) + '\n');
49
+ renameSync(tmpPath, CONFIG_PATH);
48
50
  }
49
51
 
50
- const DEFAULT_SERVER_URL = 'http://localhost:3000';
51
-
52
52
  /**
53
53
  * Escape a string for safe embedding in a single-quoted shell context.
54
54
  * Standard POSIX approach: replace each ' with '\'' (end quote, escaped quote, start quote).
@@ -59,19 +59,8 @@ export function shellEscape(str) {
59
59
  }
60
60
 
61
61
  function captureCommand() {
62
- const config = readLensConfig();
63
- const envs = [];
64
- if (config.serverUrl && config.serverUrl !== DEFAULT_SERVER_URL) {
65
- envs.push(`AI_LENS_SERVER_URL=${shellEscape(config.serverUrl)}`);
66
- }
67
- if (config.projects) {
68
- envs.push(`AI_LENS_PROJECTS=${shellEscape(config.projects)}`);
69
- }
70
- if (config.authToken) {
71
- envs.push(`AI_LENS_AUTH_TOKEN=${shellEscape(config.authToken)}`);
72
- }
73
- const base = `node ${CAPTURE_PATH}`;
74
- return envs.length > 0 ? `${envs.join(' ')} ${base}` : base;
62
+ const escaped = shellEscape(CAPTURE_PATH);
63
+ return `${shellEscape(process.execPath)} ${escaped} || node ${escaped}`;
75
64
  }
76
65
 
77
66
  // ---------------------------------------------------------------------------
@@ -169,13 +158,10 @@ export const TOOL_CONFIGS = [
169
158
  export function isAiLensHook(entry) {
170
159
  // Flat format (Cursor): { command: "..." }
171
160
  const cmd = entry?.command || '';
172
- if (cmd.includes('ai-lens') && cmd.includes('capture.js')) return true;
161
+ if (cmd.includes(CAPTURE_PATH)) return true;
173
162
  // Nested format (Claude Code): { matcher, hooks: [{ command: "..." }] }
174
163
  if (Array.isArray(entry?.hooks)) {
175
- return entry.hooks.some(h => {
176
- const c = h?.command || '';
177
- return c.includes('ai-lens') && c.includes('capture.js');
178
- });
164
+ return entry.hooks.some(h => (h?.command || '').includes(CAPTURE_PATH));
179
165
  }
180
166
  return false;
181
167
  }
@@ -345,7 +331,9 @@ export function buildStrippedConfig(tool, existingConfig) {
345
331
 
346
332
  export function writeHooksConfig(tool, config) {
347
333
  mkdirSync(dirname(tool.configPath), { recursive: true });
348
- writeFileSync(tool.configPath, JSON.stringify(config, null, 2) + '\n');
334
+ const tmpPath = tool.configPath + '.tmp.' + process.pid;
335
+ writeFileSync(tmpPath, JSON.stringify(config, null, 2) + '\n');
336
+ renameSync(tmpPath, tool.configPath);
349
337
  }
350
338
 
351
339
  // ---------------------------------------------------------------------------
package/cli/init.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createInterface } from 'node:readline';
2
2
  import { execSync } from 'node:child_process';
3
- import { existsSync } from 'node:fs';
4
- import { join } from 'node:path';
3
+ import { existsSync, copyFileSync } from 'node:fs';
4
+ import { join, resolve } 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';
@@ -229,10 +229,11 @@ async function deviceCodeAuth(serverUrl) {
229
229
 
230
230
  /**
231
231
  * Validate an existing auth token against the server.
232
- * Returns true if token is valid, false if invalid/revoked/unreachable.
232
+ * Returns 'valid', 'invalid' (401 revoked/wrong), or 'unreachable' (network error).
233
233
  */
234
234
  async function validateExistingToken(serverUrl, token) {
235
- if (!token || !token.startsWith('ailens_dev_')) return false;
235
+ if (!token) return 'invalid';
236
+ if (!token.startsWith('ailens_dev_')) return 'unknown'; // legacy format — keep it
236
237
  try {
237
238
  const parsed = new URL(`${serverUrl}/api/auth/verify`);
238
239
  const isHttps = parsed.protocol === 'https:';
@@ -253,9 +254,11 @@ async function validateExistingToken(serverUrl, token) {
253
254
  req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
254
255
  req.end();
255
256
  });
256
- return status === 200;
257
+ if (status === 200) return 'valid';
258
+ if (status === 401) return 'invalid';
259
+ return 'unreachable';
257
260
  } catch {
258
- return false;
261
+ return 'unreachable';
259
262
  }
260
263
  }
261
264
 
@@ -336,6 +339,8 @@ export default async function init() {
336
339
  );
337
340
  serverUrl = (serverInput || currentServer).replace(/\/+$/, '');
338
341
  }
342
+ if (!/^https?:\/\//i.test(serverUrl)) serverUrl = `http://${serverUrl}`;
343
+ try { new URL(serverUrl); } catch { error(`Invalid server URL: ${serverUrl}`); process.exit(1); }
339
344
  info(` Server: ${serverUrl}`);
340
345
 
341
346
  // Project filter
@@ -350,7 +355,24 @@ export default async function init() {
350
355
  const projectsInput = await ask(
351
356
  `Projects to track (comma-separated, ~ supported, Enter = ${projectsDefault}): `,
352
357
  );
353
- projects = projectsInput || currentProjects;
358
+ projects = (projectsInput && projectsInput.trim() && projectsInput.trim().toLowerCase() !== 'all')
359
+ ? projectsInput
360
+ : null;
361
+ }
362
+ // Guard: non-string (e.g. array from corrupt config) → treat as unset
363
+ if (projects && typeof projects !== 'string') {
364
+ projects = null;
365
+ }
366
+ // Guard: "all" means monitor everything → null
367
+ if (typeof projects === 'string' && projects.trim().toLowerCase() === 'all') {
368
+ projects = null;
369
+ }
370
+ // Normalize: resolve relative paths to absolute, expand ~
371
+ if (projects) {
372
+ const home = homedir();
373
+ projects = projects.split(',').map(p => p.trim()).filter(Boolean)
374
+ .map(p => p.startsWith('~/') ? join(home, p.slice(2)) : resolve(p))
375
+ .join(',');
354
376
  }
355
377
  if (projects) {
356
378
  info(` Tracking: ${projects}`);
@@ -358,23 +380,35 @@ export default async function init() {
358
380
  info(' Tracking: all projects');
359
381
  }
360
382
 
361
- // Save config if changed
383
+ // Build new config in memory — saved after "Proceed?" confirmation
362
384
  const newConfig = { ...currentConfig, serverUrl, projects };
363
- if (serverUrl !== currentConfig.serverUrl || projects !== currentConfig.projects) {
364
- saveLensConfig(newConfig);
385
+ blank();
386
+
387
+ // Install client files to ~/.ai-lens/client/
388
+ heading('Installing client files...');
389
+ try {
390
+ installClientFiles();
391
+ success(' Copied client files to ~/.ai-lens/client/');
392
+ } catch (err) {
393
+ error(` Failed to install client files: ${err.message}`);
394
+ return;
365
395
  }
366
396
  blank();
367
397
 
368
398
  // Authentication
369
399
  heading('Authentication');
370
400
  if (currentConfig.authToken) {
371
- const valid = await validateExistingToken(serverUrl, currentConfig.authToken);
372
- if (valid) {
401
+ const tokenStatus = await validateExistingToken(serverUrl, currentConfig.authToken);
402
+ if (tokenStatus === 'valid') {
373
403
  success(' Already authenticated (token verified)');
374
- } else {
404
+ } else if (tokenStatus === 'unknown') {
405
+ warn(' Token format not recognized — keeping existing token');
406
+ } else if (tokenStatus === 'invalid') {
375
407
  warn(' Existing token is invalid or revoked — re-authenticating...');
376
408
  currentConfig.authToken = null;
377
409
  newConfig.authToken = null;
410
+ } else {
411
+ warn(' Could not reach server to verify token — keeping existing token');
378
412
  }
379
413
  }
380
414
  if (!currentConfig.authToken) {
@@ -387,11 +421,23 @@ export default async function init() {
387
421
  if (err.message.includes('not configured')) {
388
422
  warn(` Auth not configured on server — personal mode (events sent via git identity)`);
389
423
  } else {
390
- error(` Authentication failed: ${err.message}`);
391
- return;
424
+ warn(` Authentication failed: ${err.message}`);
425
+ warn(` Run "npx -y ai-lens init" again later to authenticate`);
392
426
  }
393
427
  }
394
428
  }
429
+
430
+ // Validate identity: no token + no git email = events will be dropped
431
+ if (!newConfig.authToken) {
432
+ const { email } = getGitIdentity();
433
+ if (!email) {
434
+ blank();
435
+ error(' No auth token and no git email configured.');
436
+ error(' Events will be silently dropped until one is available.');
437
+ info(' Fix: git config --global user.email "you@example.com"');
438
+ info(' Or re-run init when Auth0 is configured on the server.');
439
+ }
440
+ }
395
441
  blank();
396
442
 
397
443
  // Analyze each tool
@@ -423,15 +469,17 @@ export default async function init() {
423
469
  // Filter to tools that need changes
424
470
  const pending = analyses.filter(a => a.analysis.status !== 'current');
425
471
 
426
- // Clean up legacy hook locations (always, even if current hooks are up-to-date)
427
- for (const { tool } of analyses) {
428
- for (const lr of cleanupLegacyHooks(tool)) {
429
- success(` ${tool.name}: ${lr.action} legacy hooks in ${lr.path}`);
472
+ if (pending.length === 0) {
473
+ saveLensConfig(newConfig);
474
+
475
+ // Clean up legacy hook locations (safe: hooks are already current)
476
+ for (const { tool } of analyses) {
477
+ for (const lr of cleanupLegacyHooks(tool)) {
478
+ success(` ${tool.name}: ${lr.action} legacy hooks in ${lr.path}`);
479
+ }
430
480
  }
431
- }
432
481
 
433
- if (pending.length === 0) {
434
- success('Everything is up-to-date. Nothing to do.');
482
+ success('Hooks are up-to-date.');
435
483
  } else {
436
484
  // Show plan
437
485
  heading('Plan:');
@@ -453,16 +501,15 @@ export default async function init() {
453
501
  }
454
502
  blank();
455
503
 
456
- // Install client files to ~/.ai-lens/client/
457
- heading('Installing client files...');
458
- try {
459
- installClientFiles();
460
- success(' Copied client files to ~/.ai-lens/client/');
461
- } catch (err) {
462
- error(` Failed to install client files: ${err.message}`);
463
- return;
504
+ // Persist config only after confirmation
505
+ saveLensConfig(newConfig);
506
+
507
+ // Clean up legacy hook locations before applying new ones
508
+ for (const { tool } of analyses) {
509
+ for (const lr of cleanupLegacyHooks(tool)) {
510
+ success(` ${tool.name}: ${lr.action} legacy hooks in ${lr.path}`);
511
+ }
464
512
  }
465
- blank();
466
513
 
467
514
  // Apply
468
515
  heading('Applying changes...');
@@ -470,6 +517,10 @@ export default async function init() {
470
517
 
471
518
  for (const { tool, analysis } of pending) {
472
519
  try {
520
+ // Backup malformed shared configs (e.g. ~/.claude/settings.json) before overwriting
521
+ if (analysis.status === 'malformed' && tool.sharedConfig) {
522
+ try { copyFileSync(tool.configPath, tool.configPath + '.bak'); } catch { /* file may be gone */ }
523
+ }
473
524
  const existingConfig = analysis.config || null;
474
525
  const merged = buildMergedConfig(tool, existingConfig);
475
526
  writeHooksConfig(tool, merged);
@@ -484,12 +535,14 @@ export default async function init() {
484
535
 
485
536
  // Verify hooks were written correctly
486
537
  heading('Verifying hooks...');
538
+ let verifyFailed = false;
487
539
  for (const { tool } of pending) {
488
540
  const recheck = analyzeToolHooks(tool);
489
541
  if (recheck.status === 'current') {
490
542
  success(` ${tool.name}: hooks verified`);
491
543
  } else {
492
544
  error(` ${tool.name}: hooks not current (status: ${recheck.status})`);
545
+ verifyFailed = true;
493
546
  }
494
547
  }
495
548
  blank();
@@ -503,6 +556,9 @@ export default async function init() {
503
556
  error(` ${r.tool}: failed (${r.error})`);
504
557
  }
505
558
  }
559
+ if (results.some(r => !r.ok) || verifyFailed) {
560
+ process.exitCode = 1;
561
+ }
506
562
  blank();
507
563
  }
508
564
 
package/client/capture.js CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  CAPTURE_LOG_PATH,
21
21
  captureLog,
22
22
  getServerUrl,
23
+ getAuthToken,
23
24
  getGitIdentity,
24
25
  getGitMetadata,
25
26
  getMonitoredProjects,
@@ -40,6 +41,17 @@ function logDrop(reason, meta = {}) {
40
41
  } catch { /* best-effort */ }
41
42
  }
42
43
 
44
+ // =============================================================================
45
+ // Identity Resolution
46
+ // =============================================================================
47
+
48
+ export function resolveIdentity(gitIdentity, event, hasAuthToken) {
49
+ const email = gitIdentity.email || event.user_email || null;
50
+ if (!email && !hasAuthToken) return { proceed: false, email: null, name: null };
51
+ const name = gitIdentity.name || event.user_name || email || null;
52
+ return { proceed: true, email, name };
53
+ }
54
+
43
55
  // =============================================================================
44
56
  // Truncation (reused from ai-session-lens prompts.js approach)
45
57
  // =============================================================================
@@ -161,20 +173,27 @@ function saveLastEvents(cache) {
161
173
  }
162
174
 
163
175
  /**
164
- * Returns true if this event is a duplicate that should be dropped.
165
- * Updates the cache with the current event type.
176
+ * Pure check — returns true if this event is a duplicate that should be dropped.
177
+ * Does NOT update the cache. Call commitDedup() after successful queue write.
166
178
  */
167
- export function isDuplicateEvent(sessionId, type) {
179
+ export function checkDuplicate(sessionId, source, type) {
168
180
  const cache = loadLastEvents();
169
- const prev = cache[sessionId];
170
- const dominated = DEDUP_TYPES.has(type) && prev === type;
171
- if (prev !== type) {
172
- cache[sessionId] = type;
173
- try {
174
- saveLastEvents(cache);
175
- } catch { /* dedup cache write failed proceed with stale state */ }
181
+ const key = `${source}:${sessionId}`;
182
+ const prev = cache[key];
183
+ return DEDUP_TYPES.has(type) && prev === type;
184
+ }
185
+
186
+ /**
187
+ * Commit the event type to the dedup cache.
188
+ * Call only after successful queue write to avoid cache poisoning.
189
+ */
190
+ export function commitDedup(sessionId, source, type) {
191
+ const cache = loadLastEvents();
192
+ const key = `${source}:${sessionId}`;
193
+ if (cache[key] !== type) {
194
+ cache[key] = type;
195
+ try { saveLastEvents(cache); } catch { /* best effort */ }
176
196
  }
177
- return dominated;
178
197
  }
179
198
 
180
199
  // =============================================================================
@@ -330,12 +349,27 @@ const CURSOR_TYPE_MAP = {
330
349
  sessionEnd: 'SessionEnd',
331
350
  };
332
351
 
352
+ function pickWorkspaceRoot(roots) {
353
+ if (!Array.isArray(roots) || roots.length === 0) return null;
354
+ const valid = roots.filter(r => typeof r === 'string' && r.length > 0);
355
+ if (valid.length === 0) return null;
356
+ if (valid.length === 1) return valid[0];
357
+ const monitored = getMonitoredProjects();
358
+ if (monitored) {
359
+ const match = valid.find(root =>
360
+ monitored.some(p => root === p || root.startsWith(p + '/'))
361
+ );
362
+ if (match) return match;
363
+ }
364
+ return valid[0];
365
+ }
366
+
333
367
  function normalizeCursor(event) {
334
368
  const sessionId = event.conversation_id || null;
335
369
  const hookName = event.hook_event_name;
336
370
  const type = CURSOR_TYPE_MAP[hookName] || hookName;
337
371
  const timestamp = new Date().toISOString();
338
- let projectPath = Array.isArray(event.workspace_roots) ? event.workspace_roots[0] : null;
372
+ let projectPath = pickWorkspaceRoot(event.workspace_roots);
339
373
  if (projectPath) {
340
374
  cacheSessionPath(sessionId, projectPath);
341
375
  } else {
@@ -357,10 +391,12 @@ function normalizeCursor(event) {
357
391
  input: truncateToolInput(event.tool_input, toolName),
358
392
  result: truncateToolResult(event.tool_result, toolName),
359
393
  };
394
+ const mcpServer = event.mcp_server || (toolName.startsWith('mcp__') ? toolName.split('__')[1] : null);
395
+ if (mcpServer) data.mcp_server = mcpServer;
360
396
  break;
361
397
  }
362
398
  case 'afterFileEdit':
363
- data = { file_path: event.file_path, edits: event.edits };
399
+ data = { file_path: event.file_path, edits: truncateToolResult(event.edits, 'default') };
364
400
  break;
365
401
  case 'afterShellExecution':
366
402
  data = {
@@ -371,16 +407,18 @@ function normalizeCursor(event) {
371
407
  case 'postToolUseFailure': {
372
408
  const failToolName = event.tool_name || 'unknown';
373
409
  data = {
374
- tool_name: failToolName,
375
- tool_input: truncateToolInput(event.tool_input, failToolName),
376
- error_message: truncate(event.error_message || '', 300),
377
- failure_type: event.failure_type || null,
410
+ tool: failToolName,
411
+ input: truncateToolInput(event.tool_input, failToolName),
412
+ error: truncate(event.error_message || '', 300),
413
+ failure_type: event.failure_type ?? null,
378
414
  duration: event.duration ?? null,
379
415
  };
416
+ const failMcp = event.mcp_server || (failToolName.startsWith('mcp__') ? failToolName.split('__')[1] : null);
417
+ if (failMcp) data.mcp_server = failMcp;
380
418
  break;
381
419
  }
382
420
  case 'afterMCPExecution':
383
- data = { mcp_server: event.mcp_server, result: event.result };
421
+ data = { mcp_server: event.mcp_server, result: truncateToolResult(event.result, 'default') };
384
422
  break;
385
423
  case 'subagentStart':
386
424
  data = {
@@ -486,7 +524,7 @@ async function main() {
486
524
  // Check server is configured
487
525
  const serverUrl = getServerUrl();
488
526
  if (!serverUrl) {
489
- // No server configured — silently exit
527
+ logDrop('no_server_url');
490
528
  process.exit(0);
491
529
  }
492
530
 
@@ -499,6 +537,7 @@ async function main() {
499
537
  }
500
538
 
501
539
  if (!input.trim()) {
540
+ logDrop('empty_stdin');
502
541
  process.exit(0);
503
542
  }
504
543
 
@@ -506,7 +545,7 @@ async function main() {
506
545
  try {
507
546
  event = JSON.parse(input);
508
547
  } catch {
509
- // Malformed stdin exit gracefully
548
+ logDrop('malformed_json', { input_length: input.length, first_chars: input.slice(0, 100) });
510
549
  process.exit(0);
511
550
  }
512
551
 
@@ -516,28 +555,35 @@ async function main() {
516
555
  process.exit(0);
517
556
  }
518
557
 
519
- // Deduplicate consecutive identical event types (e.g. repeated Stop from idle sessions)
520
- if (isDuplicateEvent(unified.session_id, unified.type)) {
521
- logDrop('duplicate', { type: unified.type, session_id: unified.session_id });
522
- process.exit(0);
523
- }
524
-
525
558
  // Filter by monitored projects (if configured)
526
559
  const monitored = getMonitoredProjects();
527
- if (monitored && !monitored.some(p => unified.project_path === p || unified.project_path?.startsWith(p + '/'))) {
528
- logDrop('project_filter', { type: unified.type, source: unified.source, session_id: unified.session_id, project_path: unified.project_path, monitored });
529
- process.exit(0);
560
+ if (monitored && unified.project_path && !monitored.some(p => unified.project_path === p || unified.project_path.startsWith(p + '/'))) {
561
+ // Fallback: for Cursor multi-root workspaces, check if any raw workspace_roots entry matches
562
+ const roots = Array.isArray(event.workspace_roots) ? event.workspace_roots : [];
563
+ if (!roots.some(root => monitored.some(p => root === p || root.startsWith(p + '/')))) {
564
+ logDrop('project_filter', { type: unified.type, source: unified.source, session_id: unified.session_id, project_path: unified.project_path, monitored });
565
+ process.exit(0);
566
+ }
530
567
  }
531
568
 
532
569
  // Resolve identity: git first, then fall back to event payload (e.g. Cursor's user_email)
570
+ // When auth token is present, server resolves developer from token — email is optional
533
571
  const identity = getGitIdentity();
534
- const email = identity.email || event.user_email || null;
535
- if (!email) {
572
+ const hasAuthToken = !!getAuthToken();
573
+ const resolved = resolveIdentity(identity, event, hasAuthToken);
574
+ if (!resolved.proceed) {
536
575
  logDrop('no_email', { type: unified.type, session_id: unified.session_id });
537
576
  process.exit(0);
538
577
  }
539
- unified.developer_email = email;
540
- unified.developer_name = identity.name || event.user_name || email;
578
+
579
+ // Deduplicate consecutive identical event types (e.g. repeated Stop from idle sessions)
580
+ // Placed after project_filter and no_email checks so dropped events don't poison the cache
581
+ if (checkDuplicate(unified.session_id, unified.source, unified.type)) {
582
+ logDrop('duplicate', { type: unified.type, session_id: unified.session_id });
583
+ process.exit(0);
584
+ }
585
+ unified.developer_email = resolved.email;
586
+ unified.developer_name = resolved.name;
541
587
 
542
588
  // Attach git metadata (remote, branch, commit)
543
589
  const gitMeta = getGitMetadata(unified.project_path);
@@ -553,6 +599,9 @@ async function main() {
553
599
  process.exit(1);
554
600
  }
555
601
 
602
+ // Commit dedup cache only after successful queue write (avoids cache poisoning on write failure)
603
+ commitDedup(unified.session_id, unified.source, unified.type);
604
+
556
605
  // Always try to spawn sender — atomic rename in sender handles dedup
557
606
  try {
558
607
  trySpawnSender();
package/client/config.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { mkdirSync, appendFileSync, readFileSync, writeFileSync, existsSync, renameSync } from 'node:fs';
2
- import { join } from 'node:path';
2
+ import { join, resolve } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
4
  import { execSync } from 'node:child_process';
5
5
 
@@ -30,7 +30,11 @@ function loadConfig() {
30
30
  if (_configCache !== undefined) return _configCache;
31
31
  try {
32
32
  _configCache = JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'));
33
- } catch {
33
+ } catch (err) {
34
+ if (err.code !== 'ENOENT') {
35
+ captureLog({ msg: 'config-parse-error', path: CONFIG_PATH, error: err.message });
36
+ console.error(`[ai-lens] config.json corrupt, falling back to defaults: ${err.message}`);
37
+ }
34
38
  _configCache = {};
35
39
  }
36
40
  return _configCache;
@@ -54,9 +58,14 @@ export function getServerUrl() {
54
58
  export function getMonitoredProjects() {
55
59
  const val = process.env.AI_LENS_PROJECTS || loadConfig().projects;
56
60
  if (!val) return null; // null = monitor everything
61
+ if (typeof val !== 'string') return null; // non-string (e.g. array) = treat as unset
62
+ if (val.trim().toLowerCase() === 'all') return null;
63
+ const paths = val.split(',').map(p => p.trim()).filter(Boolean);
64
+ if (paths.length === 0) return null;
57
65
  const home = homedir();
58
- return val.split(',').map(p => p.trim()).filter(Boolean)
66
+ return paths
59
67
  .map(p => p.startsWith('~/') ? join(home, p.slice(2)) : p)
68
+ .map(p => resolve(p))
60
69
  .map(p => p.endsWith('/') ? p.slice(0, -1) : p);
61
70
  }
62
71
 
@@ -70,14 +79,14 @@ export function getGitIdentity() {
70
79
 
71
80
  try {
72
81
  email = execSync('git config user.email', { encoding: 'utf-8', timeout: 3000 }).trim();
73
- } catch {
74
- // git not configured
82
+ } catch (err) {
83
+ captureLog({ msg: 'git-email-failed', error: err.message?.split('\n')[0] });
75
84
  }
76
85
 
77
86
  try {
78
87
  name = execSync('git config user.name', { encoding: 'utf-8', timeout: 3000 }).trim();
79
88
  } catch {
80
- // git not configured
89
+ // git name missing is non-critical — email or token is sufficient
81
90
  }
82
91
 
83
92
  return { email, name };
@@ -112,7 +121,9 @@ function cacheRemote(projectPath, remote) {
112
121
  const remotes = loadGitRemotes();
113
122
  if (remotes[projectPath] !== remote) {
114
123
  remotes[projectPath] = remote;
115
- saveGitRemotes(remotes);
124
+ try {
125
+ saveGitRemotes(remotes);
126
+ } catch { /* cache write failed — event proceeds without cached remote */ }
116
127
  }
117
128
  }
118
129
 
package/client/redact.js CHANGED
@@ -34,11 +34,20 @@ const PATTERNS = [
34
34
  { type: 'JWT', re: /eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_\-.+/=]{10,}/g },
35
35
 
36
36
  // PEM private keys — full block or truncated (just the header + base64 content)
37
- { type: 'PRIVATE_KEY', re: /-----BEGIN[A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END[A-Z ]*PRIVATE KEY-----|$)/g },
37
+ { type: 'PRIVATE_KEY', re: /-----BEGIN[A-Z ]*PRIVATE KEY-----[A-Za-z0-9+/=\s\\.]*(?:-----END[A-Z ]*PRIVATE KEY-----)?/g },
38
38
 
39
39
  // Connection string password (://user:password@host) — redacts password only
40
40
  { type: 'CONNECTION_STRING', re: /:\/\/([^:@\s]+):([^@\s]{3,})@/g, replacer: (m, user, _pw) => `://${user}:[REDACTED:CONNECTION_STRING]@` },
41
41
 
42
+ // Environment variables: UPPER_CASE_VAR with secret keyword = value
43
+ // Catches AI_LENS_AUTH_TOKEN=..., PGPASSWORD=..., AWS_SECRET_ACCESS_KEY=..., etc.
44
+ // Skips template refs like ${VAR} since value excludes { } chars.
45
+ {
46
+ type: 'ENV_VAR',
47
+ re: /([A-Z_]*(?:SECRET|TOKEN|PASSWORD|PASSWD|API_KEY|APIKEY)[A-Z_]*\s*=\s*["']?)([^\s"';\\\`|>{}\[\]]{8,})/g,
48
+ replacer: (m, prefix, _val) => `${prefix}[REDACTED:ENV_VAR]`,
49
+ },
50
+
42
51
  // Key-value pairs: password=..., token: ..., etc.
43
52
  {
44
53
  type: 'KEY_VALUE',
package/client/sender.js CHANGED
@@ -10,7 +10,7 @@
10
10
  * - Rollback on failure: unsent events prepended back to queue.jsonl
11
11
  */
12
12
 
13
- import { readFileSync, writeFileSync, unlinkSync, renameSync } from 'node:fs';
13
+ import { readFileSync, writeFileSync, appendFileSync, unlinkSync, renameSync } from 'node:fs';
14
14
  import { request as httpsRequest } from 'node:https';
15
15
  import { request as httpRequest } from 'node:http';
16
16
  import { fileURLToPath } from 'node:url';
@@ -56,19 +56,32 @@ export function parseQueueContent(content) {
56
56
 
57
57
  /**
58
58
  * Group events by developer_email.
59
- * Events without developer_email are skipped.
60
- * Returns Map<email, { identity: { email, name }, events: [] }>
59
+ * Events without developer_email are skipped unless hasAuthToken is true,
60
+ * in which case they are grouped under a special '__token_auth__' key
61
+ * (server resolves identity from the token).
62
+ * Returns Map<email|'__token_auth__', { identity: { email, name }, events: [] }>
61
63
  */
62
- export function groupByDeveloper(events) {
64
+ export function groupByDeveloper(events, hasAuthToken = false) {
65
+ const TOKEN_KEY = '__token_auth__';
63
66
  const byDeveloper = new Map();
67
+ let skippedNoEmail = 0;
64
68
  for (const evt of events) {
65
69
  const email = evt.developer_email;
66
- if (!email) continue;
70
+ if (!email) {
71
+ if (!hasAuthToken) { skippedNoEmail++; continue; }
72
+ if (!byDeveloper.has(TOKEN_KEY))
73
+ byDeveloper.set(TOKEN_KEY, { identity: { email: null, name: null }, events: [] });
74
+ byDeveloper.get(TOKEN_KEY).events.push(evt);
75
+ continue;
76
+ }
67
77
  if (!byDeveloper.has(email)) {
68
78
  byDeveloper.set(email, { identity: { email, name: evt.developer_name || email }, events: [] });
69
79
  }
70
80
  byDeveloper.get(email).events.push(evt);
71
81
  }
82
+ if (skippedNoEmail > 0) {
83
+ log({ msg: 'skip-no-email', skipped: skippedNoEmail, total: events.length });
84
+ }
72
85
  return byDeveloper;
73
86
  }
74
87
 
@@ -79,6 +92,41 @@ export function buildRollbackContent(unsentContent, existingContent) {
79
92
  return unsentContent + existingContent;
80
93
  }
81
94
 
95
+ /**
96
+ * Check if a sender lock file is stale (owner process no longer running).
97
+ * Returns true if lock is missing or process is dead.
98
+ */
99
+ export function isLockStale(lockPath) {
100
+ try {
101
+ const pid = parseInt(readFileSync(lockPath, 'utf-8').trim(), 10);
102
+ process.kill(pid, 0); // throws if process doesn't exist
103
+ return false; // process alive — lock is active
104
+ } catch (err) {
105
+ if (err.code === 'ESRCH') return true; // process dead — stale
106
+ if (err.code === 'ENOENT') return true; // no lock file
107
+ return false; // permission error etc — assume active
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Merge unsent content back into the queue without losing concurrent appends.
113
+ * Uses atomic rename to drain current queue before merging.
114
+ */
115
+ export function mergeToQueue(unsentContent, queuePath) {
116
+ const tmpPath = queuePath + '.rollback.' + process.pid;
117
+ writeFileSync(tmpPath, unsentContent);
118
+ const drainPath = queuePath + '.drain.' + process.pid;
119
+ try {
120
+ renameSync(queuePath, drainPath);
121
+ appendFileSync(tmpPath, readFileSync(drainPath, 'utf-8'));
122
+ unlinkSync(drainPath);
123
+ } catch (err) {
124
+ if (err.code !== 'ENOENT') throw err;
125
+ // No queue existed — nothing to drain
126
+ }
127
+ renameSync(tmpPath, queuePath);
128
+ }
129
+
82
130
  /**
83
131
  * Atomically acquire the queue for sending.
84
132
  * renameSync is atomic on POSIX — acts as a mutex.
@@ -89,16 +137,22 @@ export function buildRollbackContent(unsentContent, existingContent) {
89
137
  * @param {string} [sendingPath] - Override sending path (for testing); defaults to SENDING_PATH
90
138
  */
91
139
  export function acquireQueue(queuePath = QUEUE_PATH, sendingPath = SENDING_PATH) {
92
- // Recover orphaned sending file from a previously crashed sender
140
+ const lockPath = sendingPath + '.lock';
141
+
142
+ // Recover orphaned sending file from a previously crashed sender.
143
+ // Only recover if the lock is stale (owner process is dead).
93
144
  try {
94
145
  const orphaned = readFileSync(sendingPath, 'utf-8');
146
+ if (!isLockStale(lockPath)) {
147
+ // Another sender is actively working — exit without touching its file
148
+ return null;
149
+ }
95
150
  if (orphaned.trim()) {
96
- let existing = '';
97
- try { existing = readFileSync(queuePath, 'utf-8'); } catch { /* no queue yet */ }
98
- writeFileSync(queuePath, orphaned + existing);
151
+ mergeToQueue(orphaned, queuePath);
99
152
  log({ msg: 'orphan-recovered', bytes: Buffer.byteLength(orphaned) });
100
153
  }
101
154
  unlinkSync(sendingPath);
155
+ try { unlinkSync(lockPath); } catch { /* stale lock already gone */ }
102
156
  } catch (err) {
103
157
  if (err.code !== 'ENOENT') throw err;
104
158
  // No orphan — normal path
@@ -111,12 +165,16 @@ export function acquireQueue(queuePath = QUEUE_PATH, sendingPath = SENDING_PATH)
111
165
  throw err;
112
166
  }
113
167
 
168
+ // Write PID lock so other senders know we're active
169
+ writeFileSync(lockPath, String(process.pid));
170
+
114
171
  const content = readFileSync(sendingPath, 'utf-8');
115
172
  const { events, dropped, overflow } = parseQueueContent(content);
116
173
  if (dropped > 0) log({ msg: 'queue-corruption', dropped });
117
174
  if (overflow > 0) log({ msg: 'queue-overflow', dropped: overflow, kept: MAX_QUEUE_SIZE });
118
175
  if (events.length === 0) {
119
176
  unlinkSync(sendingPath);
177
+ try { unlinkSync(lockPath); } catch {}
120
178
  return null;
121
179
  }
122
180
 
@@ -128,6 +186,7 @@ export function acquireQueue(queuePath = QUEUE_PATH, sendingPath = SENDING_PATH)
128
186
  */
129
187
  function commitQueue(sendingPath) {
130
188
  try { unlinkSync(sendingPath); } catch { /* already gone */ }
189
+ try { unlinkSync(sendingPath + '.lock'); } catch { /* already gone */ }
131
190
  }
132
191
 
133
192
  /**
@@ -137,10 +196,9 @@ function commitQueue(sendingPath) {
137
196
  function rollbackQueue(sendingPath, eventCount) {
138
197
  try {
139
198
  const unsent = readFileSync(sendingPath, 'utf-8');
140
- let existing = '';
141
- try { existing = readFileSync(QUEUE_PATH, 'utf-8'); } catch { /* no new events */ }
142
- writeFileSync(QUEUE_PATH, buildRollbackContent(unsent, existing));
199
+ mergeToQueue(unsent, QUEUE_PATH);
143
200
  unlinkSync(sendingPath);
201
+ try { unlinkSync(sendingPath + '.lock'); } catch {}
144
202
  log({ msg: 'rollback', events: eventCount });
145
203
  } catch { /* best effort */ }
146
204
  }
@@ -155,13 +213,12 @@ function rollbackQueue(sendingPath, eventCount) {
155
213
  */
156
214
  export function partialRollback(sendingPath, unsentEvents, totalCount, queuePath = QUEUE_PATH) {
157
215
  try {
158
- let existing = '';
159
- try { existing = readFileSync(queuePath, 'utf-8'); } catch { /* no new events */ }
160
216
  if (unsentEvents.length > 0) {
161
217
  const unsentContent = unsentEvents.map(e => JSON.stringify(e)).join('\n') + '\n';
162
- writeFileSync(queuePath, unsentContent + existing);
218
+ mergeToQueue(unsentContent, queuePath);
163
219
  }
164
220
  try { unlinkSync(sendingPath); } catch { /* already gone */ }
221
+ try { unlinkSync(sendingPath + '.lock'); } catch { /* already gone */ }
165
222
  log({ msg: 'partial-rollback', sent: totalCount - unsentEvents.length, unsent: unsentEvents.length });
166
223
  } catch (rollbackErr) {
167
224
  log({ msg: 'rollback-failed', error: rollbackErr.message });
@@ -207,16 +264,16 @@ export function chunkEvents(events, maxBytes = MAX_CHUNK_BYTES) {
207
264
  function postEvents(serverUrl, events, identity) {
208
265
  return new Promise((resolve, reject) => {
209
266
  const body = JSON.stringify(events);
210
- const url = new URL('/api/events', serverUrl);
267
+ const url = new URL(`${serverUrl}/api/events`);
211
268
  const isHttps = url.protocol === 'https:';
212
269
  const requestFn = isHttps ? httpsRequest : httpRequest;
213
270
 
214
271
  const headers = {
215
272
  'Content-Type': 'application/json',
216
273
  'Content-Length': Buffer.byteLength(body),
217
- 'X-Developer-Git-Email': identity.email,
218
- 'X-Developer-Name': encodeURIComponent(identity.name),
219
274
  };
275
+ if (identity.email) headers['X-Developer-Git-Email'] = identity.email;
276
+ if (identity.name) headers['X-Developer-Name'] = encodeURIComponent(identity.name);
220
277
 
221
278
  const authToken = getAuthToken();
222
279
  if (authToken) {
@@ -269,9 +326,12 @@ async function main() {
269
326
  const { events, sendingPath } = acquired;
270
327
 
271
328
  // Group events by developer_email (baked in at capture time)
272
- const byDeveloper = groupByDeveloper(events);
329
+ // When auth token is present, null-email events are grouped for token-based identity
330
+ const hasAuthToken = !!getAuthToken();
331
+ const byDeveloper = groupByDeveloper(events, hasAuthToken);
273
332
 
274
333
  if (byDeveloper.size === 0) {
334
+ log({ msg: 'queue-empty-after-grouping', total_events: events.length, has_auth_token: hasAuthToken });
275
335
  commitQueue(sendingPath);
276
336
  process.exit(0);
277
337
  }
@@ -296,6 +356,9 @@ async function main() {
296
356
  for (const chunk of chunks) {
297
357
  const result = await postEvents(serverUrl, chunk, identity);
298
358
  totalReceived += result.received;
359
+ if (result.skipped > 0) {
360
+ log({ msg: 'server-skipped', skipped: result.skipped, chunk_size: chunk.length, developer: identity.email });
361
+ }
299
362
  for (const evt of chunk) {
300
363
  if (evt.event_id) sentEventIds.add(evt.event_id);
301
364
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-lens",
3
- "version": "0.7.3",
3
+ "version": "0.7.4",
4
4
  "type": "module",
5
5
  "description": "Centralized session analytics for AI coding tools",
6
6
  "bin": {