ai-lens 0.8.20 → 0.8.22

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
- 00dc13f
1
+ 23120cb
package/cli/hooks.js CHANGED
@@ -106,9 +106,15 @@ export function captureCommand() {
106
106
  const capturePath = CAPTURE_PATH.replace(/\\/g, '/');
107
107
  const escaped = shellEscape(capturePath);
108
108
  // /usr/bin/env doesn't need shell-escaping, but named paths do
109
- return nodePath === '/usr/bin/env node'
109
+ const base = nodePath === '/usr/bin/env node'
110
110
  ? `/usr/bin/env node ${escaped}`
111
111
  : `${shellEscape(nodePath.replace(/\\/g, '/'))} ${escaped}`;
112
+ // On Windows, prefix with "& " so the command works in both PowerShell and cmd.exe.
113
+ // PowerShell treats a quoted path like "node.exe" as a string expression, not a command;
114
+ // the & (call) operator is required. In cmd.exe, & is a command separator — the empty
115
+ // first part is a no-op, and the real command runs as the second part.
116
+ if (process.platform === 'win32') return `& ${base}`;
117
+ return base;
112
118
  }
113
119
 
114
120
  // ---------------------------------------------------------------------------
package/cli/status.js CHANGED
@@ -5,7 +5,8 @@ import { homedir, release as osRelease, arch as osArch } from 'node:os';
5
5
  import { randomUUID } from 'node:crypto';
6
6
 
7
7
  import { getVersionInfo, readLensConfig, detectInstalledTools, analyzeToolHooks, checkHooksDisabled, CAPTURE_PATH, TOOL_CONFIGS } from './hooks.js';
8
- import { DATA_DIR, PENDING_DIR, SENDING_DIR, LOG_PATH, CAPTURE_LOG_PATH, getGitIdentity } from '../client/config.js';
8
+ import { DATA_DIR, PENDING_DIR, SENDING_DIR, SESSION_PATHS_DIR, LOG_PATH, CAPTURE_LOG_PATH, getGitIdentity } from '../client/config.js';
9
+ import { isLockStale } from '../client/sender.js';
9
10
  import { initLogger, info, success, warn, error, heading, blank } from './logger.js';
10
11
 
11
12
  const INIT_LOG_PATH = join(DATA_DIR, 'init.log');
@@ -18,8 +19,11 @@ const DIM = useColor ? '\x1b[2m' : '';
18
19
  const GREEN = useColor ? '\x1b[32m' : '';
19
20
  const RED = useColor ? '\x1b[31m' : '';
20
21
 
22
+ const YELLOW = useColor ? '\x1b[33m' : '';
23
+
21
24
  const CHECK = `${GREEN}\u2713${RESET}`;
22
25
  const CROSS = `${RED}\u2717${RESET}`;
26
+ const TRIANGLE = `${YELLOW}\u26a0${RESET}`;
23
27
 
24
28
  const REPORT_PATH = join(homedir(), 'ai-lens-status.txt');
25
29
 
@@ -78,6 +82,37 @@ function extractHookCommand(tool) {
78
82
  return null;
79
83
  }
80
84
 
85
+ function validateHookCommandPaths(tool) {
86
+ const command = extractHookCommand(tool);
87
+ if (!command) return null;
88
+
89
+ const issues = [];
90
+
91
+ // Extract capture.js path using 'capture.js' as anchor
92
+ const captureMatch = command.match(/["']([^"']*capture\.js)["']|(\S*capture\.js)/);
93
+ if (captureMatch) {
94
+ const capturePath = captureMatch[1] || captureMatch[2];
95
+ if (!existsSync(capturePath)) {
96
+ issues.push(`capture.js not found at: ${capturePath}`);
97
+ }
98
+ }
99
+
100
+ // Extract node path (first token). Skip if /usr/bin/env node (node resolved via PATH)
101
+ // Strip "& " prefix (PowerShell call operator, added on Windows) before matching.
102
+ const cmdForNode = command.replace(/^& /, '');
103
+ if (!cmdForNode.startsWith('/usr/bin/env node')) {
104
+ const nodeMatch = cmdForNode.match(/^["']([^"']+)["']|^(\S+)/);
105
+ if (nodeMatch) {
106
+ const nodePath = nodeMatch[1] || nodeMatch[2];
107
+ if (nodePath !== 'node' && !existsSync(nodePath)) {
108
+ issues.push(`node not found at: ${nodePath}`);
109
+ }
110
+ }
111
+ }
112
+
113
+ return issues.length > 0 ? issues : null;
114
+ }
115
+
81
116
  function checkCaptureRun(installedTools) {
82
117
  // Collect commands from ALL installed tools — not just the first found (Issues 8-9).
83
118
  // If Claude Code works but Cursor is broken, both must be tested to surface the failure.
@@ -141,21 +176,72 @@ function checkCaptureRun(installedTools) {
141
176
  }
142
177
  } catch { /* best effort */ }
143
178
 
144
- toolResults.push({ name, ok: exitOk, cmd: testCmd, exitDetail, captureNote });
179
+ // Shell command test: execute the actual hook command string through the shell
180
+ // to catch path/quoting/slash issues that direct spawn bypasses.
181
+ // This simulates how Claude Code / Cursor actually invoke hooks on the OS.
182
+ const shellSessionId = 'status-shell-' + Date.now() + '-' + Math.random().toString(36).slice(2, 7);
183
+ const shellEvent = JSON.stringify({
184
+ hook_event_name: 'Stop',
185
+ session_id: shellSessionId,
186
+ stop_reason: 'test',
187
+ });
188
+ let shellOk = false;
189
+ let shellDetail = '';
190
+ try {
191
+ const shellResult = spawnSync(command, {
192
+ input: shellEvent,
193
+ shell: true,
194
+ encoding: 'utf-8',
195
+ timeout: 10_000,
196
+ env: { ...process.env, AI_LENS_PROJECTS: join(homedir(), '.ai-lens-status-check-nonexistent') },
197
+ windowsHide: true,
198
+ });
199
+ if (shellResult.error) throw shellResult.error;
200
+ shellOk = shellResult.status === 0;
201
+ shellDetail = shellOk ? 'exit 0' : `Exit code: ${shellResult.status}\nError: ${(shellResult.stderr || '').trim() || '(no stderr)'}`;
202
+ } catch (err) {
203
+ shellDetail = `Exit code: N/A\nError: ${err.message}`;
204
+ }
205
+
206
+ let shellCaptureNote = '';
207
+ try {
208
+ if (existsSync(CAPTURE_LOG_PATH)) {
209
+ const logLines = readFileSync(CAPTURE_LOG_PATH, 'utf-8').split(/\r?\n/).filter(Boolean);
210
+ for (let i = logLines.length - 1; i >= 0; i--) {
211
+ try {
212
+ const entry = JSON.parse(logLines[i]);
213
+ if (entry.session_id === shellSessionId) {
214
+ shellCaptureNote = entry.reason || entry.msg || 'unknown';
215
+ break;
216
+ }
217
+ } catch { /* skip unparseable lines */ }
218
+ }
219
+ }
220
+ } catch { /* best effort */ }
221
+
222
+ toolResults.push({ name, ok: exitOk, cmd: testCmd, exitDetail, captureNote, shellOk, shellCmd: command, shellDetail, shellCaptureNote });
145
223
  }
146
224
 
147
- const allOk = toolResults.every(r => r.ok);
148
- const failedTools = toolResults.filter(r => !r.ok).map(r => r.name);
225
+ const allOk = toolResults.every(r => r.ok && r.shellOk);
226
+ const failedTools = toolResults.filter(r => !r.ok || !r.shellOk).map(r => r.name);
149
227
  const summaryParts = toolResults.map(r => {
150
- const status = r.ok ? 'OK' : 'FAILED';
151
- return r.captureNote ? `${r.name}: ${status} (${r.captureNote})` : `${r.name}: ${status}`;
228
+ const directStatus = r.ok ? 'OK' : 'FAILED';
229
+ const shellStatus = r.shellOk ? 'OK' : 'FAILED';
230
+ const parts = [`${r.name}: ${directStatus}`];
231
+ if (!r.shellOk) parts[0] += `, shell: ${shellStatus}`;
232
+ if (r.captureNote) parts[0] += ` (${r.captureNote})`;
233
+ return parts[0];
152
234
  });
153
235
  const summary = allOk
154
236
  ? `capture runs OK (${summaryParts.join(', ')})`
155
237
  : `capture failed for: ${failedTools.join(', ')}`;
156
- const detail = toolResults.map(r =>
157
- `[${r.name}]\n Ran: ${r.cmd}\n Result: ${r.exitDetail}${r.captureNote ? `\n Capture log: ${r.captureNote}` : ''}`
158
- ).join('\n\n');
238
+ const detail = toolResults.map(r => {
239
+ let text = `[${r.name}]\n Ran: ${r.cmd}\n Result: ${r.exitDetail}`;
240
+ if (r.captureNote) text += `\n Capture log: ${r.captureNote}`;
241
+ text += `\n Shell: ${r.shellCmd} < (test event)\n Shell result: ${r.shellDetail}`;
242
+ if (r.shellCaptureNote) text += `\n Shell capture log: ${r.shellCaptureNote}`;
243
+ return text;
244
+ }).join('\n\n');
159
245
 
160
246
  return { ok: allOk, summary, detail };
161
247
  }
@@ -277,6 +363,18 @@ function checkHooks(tool) {
277
363
  };
278
364
  }
279
365
 
366
+ // Path validation: when hooks are present, verify command paths exist
367
+ if (mapped.ok === true) {
368
+ const pathIssues = validateHookCommandPaths(tool);
369
+ if (pathIssues) {
370
+ return {
371
+ ok: false,
372
+ summary: 'hooks current BUT command path missing',
373
+ detail: `${detail}\n\nCommand path issues:\n${pathIssues.map(i => ` \u2717 ${i}`).join('\n')}`,
374
+ };
375
+ }
376
+ }
377
+
280
378
  return { ok: mapped.ok, summary: mapped.label, detail };
281
379
  }
282
380
 
@@ -298,8 +396,15 @@ function checkQueue() {
298
396
  }
299
397
  } catch { /* best effort */ }
300
398
 
399
+ // Check sender lock
400
+ const lockPath = join(SENDING_DIR, '.sender.lock');
401
+ let lockStatus = 'none';
402
+ if (existsSync(lockPath)) {
403
+ lockStatus = isLockStale(lockPath) ? 'stale' : 'active';
404
+ }
405
+
301
406
  // Show last 3 pending events (read individual files)
302
- let detail = `Pending dir: ${PENDING_DIR}\nPending: ${pendingCount} events\nSending: ${sendingCount} events (in-flight)`;
407
+ let detail = `Pending dir: ${PENDING_DIR}\nPending: ${pendingCount} events\nSending: ${sendingCount} events (in-flight)\nSender lock: ${lockStatus}`;
303
408
  if (pendingCount > 0) {
304
409
  try {
305
410
  const files = readdirSync(PENDING_DIR).filter(f => f.endsWith('.json')).sort().slice(-3);
@@ -315,11 +420,13 @@ function checkQueue() {
315
420
  } catch { /* best effort */ }
316
421
  }
317
422
 
318
- const ok = pendingCount < 100; // warn if many events are stuck
319
- const summary = pendingCount === 0 && sendingCount === 0
423
+ const ok = pendingCount < 100 && lockStatus !== 'stale';
424
+ let summary = pendingCount === 0 && sendingCount === 0
320
425
  ? 'empty (0 events)'
321
426
  : `${pendingCount} pending, ${sendingCount} sending`;
322
- return { ok, summary, detail, lineCount: pendingCount };
427
+ if (lockStatus === 'stale') summary += ', STALE lock';
428
+ else if (lockStatus === 'active') summary += ', sender active';
429
+ return { ok, summary, detail, lineCount: pendingCount, lockStatus };
323
430
  }
324
431
 
325
432
  function checkSenderLog() {
@@ -424,6 +531,88 @@ function checkCaptureLog() {
424
531
  };
425
532
  }
426
533
 
534
+ function checkRealActivity() {
535
+ try {
536
+ if (!existsSync(SESSION_PATHS_DIR)) {
537
+ return { ok: false, summary: 'no real sessions ever captured', detail: `Session paths dir not found: ${SESSION_PATHS_DIR}`, hasEverCaptured: false, lastCaptureMs: 0 };
538
+ }
539
+
540
+ const files = readdirSync(SESSION_PATHS_DIR).filter(f => !f.startsWith('.'));
541
+ if (files.length === 0) {
542
+ return { ok: false, summary: 'no real sessions ever captured', detail: `Session paths dir empty: ${SESSION_PATHS_DIR}`, hasEverCaptured: false, lastCaptureMs: 0 };
543
+ }
544
+
545
+ // Find newest file by mtime
546
+ let newestMtime = 0;
547
+ for (const file of files) {
548
+ try {
549
+ const st = statSync(join(SESSION_PATHS_DIR, file));
550
+ if (st.mtimeMs > newestMtime) newestMtime = st.mtimeMs;
551
+ } catch { /* skip unreadable */ }
552
+ }
553
+
554
+ const lastCaptureAgo = newestMtime ? relativeTime(new Date(newestMtime).toISOString()) : '(unknown)';
555
+
556
+ return {
557
+ ok: true,
558
+ summary: `${files.length} sessions, last real capture ${lastCaptureAgo}`,
559
+ detail: `Session paths dir: ${SESSION_PATHS_DIR}\nSession files: ${files.length}\nLast capture: ${lastCaptureAgo}`,
560
+ hasEverCaptured: true,
561
+ lastCaptureMs: newestMtime,
562
+ };
563
+ } catch (err) {
564
+ return { ok: null, summary: `error: ${err.message}`, detail: `Error reading session paths: ${err.message}`, hasEverCaptured: false, lastCaptureMs: 0 };
565
+ }
566
+ }
567
+
568
+ async function checkServerSessions(serverUrl, authToken) {
569
+ if (!serverUrl || !authToken) {
570
+ const missing = !serverUrl ? 'server URL' : 'auth token';
571
+ return { ok: null, summary: `skipped (no ${missing})`, detail: `Cannot check server sessions: missing ${missing}`, sessionCount: null };
572
+ }
573
+
574
+ const url = `${serverUrl}/api/sessions?days=7`;
575
+ try {
576
+ const res = await fetch(url, {
577
+ headers: { 'X-Auth-Token': authToken },
578
+ signal: AbortSignal.timeout(8000),
579
+ });
580
+ if (!res.ok) {
581
+ return { ok: null, summary: `HTTP ${res.status}`, detail: `GET ${url} \u2192 ${res.status}`, sessionCount: null };
582
+ }
583
+
584
+ const data = await res.json();
585
+ const sessions = Array.isArray(data) ? data : (data.sessions || []);
586
+ const count = sessions.length;
587
+
588
+ // Group by source
589
+ const bySource = {};
590
+ for (const s of sessions) {
591
+ const src = s.source || 'unknown';
592
+ bySource[src] = (bySource[src] || 0) + 1;
593
+ }
594
+ const breakdown = Object.entries(bySource).map(([s, n]) => `${s}: ${n}`).join(', ');
595
+
596
+ if (count === 0) {
597
+ return {
598
+ ok: false,
599
+ summary: 'no sessions on server in last 7 days',
600
+ detail: `GET ${url} \u2192 0 sessions`,
601
+ sessionCount: 0,
602
+ };
603
+ }
604
+
605
+ return {
606
+ ok: true,
607
+ summary: `${count} sessions in last 7 days${breakdown ? ` (${breakdown})` : ''}`,
608
+ detail: `GET ${url} \u2192 ${count} sessions\nBy source: ${breakdown}`,
609
+ sessionCount: count,
610
+ };
611
+ } catch (err) {
612
+ return { ok: null, summary: `error (${err.message})`, detail: `GET ${url}\nError: ${err.message}`, sessionCount: null };
613
+ }
614
+ }
615
+
427
616
  async function checkServer(serverUrl) {
428
617
  if (!serverUrl) {
429
618
  return { ok: false, summary: 'no server URL configured', detail: 'Cannot check server: no serverUrl in config' };
@@ -521,11 +710,65 @@ async function checkE2e(serverUrl, authToken) {
521
710
  }
522
711
  }
523
712
 
713
+ // ---------------------------------------------------------------------------
714
+ // Summary warnings
715
+ // ---------------------------------------------------------------------------
716
+
717
+ function buildWarnings(results, { hasEverCaptured, lastCaptureMs, serverSessionCount, queuePending, lockStatus, serverReachable }) {
718
+ const warnings = [];
719
+
720
+ // All checks pass but no real events captured
721
+ if (hasEverCaptured === false) {
722
+ const allChecksOk = results.every(r => r.ok !== false);
723
+ if (allChecksOk) {
724
+ warnings.push({
725
+ msg: 'All checks pass but no real events captured.',
726
+ action: 'Restart Claude Code / Cursor to activate hooks.',
727
+ });
728
+ }
729
+ }
730
+
731
+ // Local events exist but server has none
732
+ if (hasEverCaptured && serverSessionCount === 0) {
733
+ warnings.push({
734
+ msg: 'Events captured locally but server has 0 sessions in last 7 days.',
735
+ action: 'Check sender.log for errors.',
736
+ });
737
+ }
738
+
739
+ // Last real capture > 24h ago
740
+ if (lastCaptureMs && Date.now() - lastCaptureMs > 24 * 60 * 60 * 1000) {
741
+ const ago = relativeTime(new Date(lastCaptureMs).toISOString());
742
+ warnings.push({
743
+ msg: `Last real capture was ${ago}.`,
744
+ action: 'If you\'ve worked since then, hooks may not be firing. Restart your IDE.',
745
+ });
746
+ }
747
+
748
+ // Queue has many events pending while server is reachable
749
+ if (queuePending > 50 && serverReachable) {
750
+ warnings.push({
751
+ msg: `Queue has ${queuePending} pending events.`,
752
+ action: 'Sender may be stuck. Check sender.log.',
753
+ });
754
+ }
755
+
756
+ // Stale sender lock
757
+ if (lockStatus === 'stale') {
758
+ warnings.push({
759
+ msg: 'Sender lock is stale.',
760
+ action: 'Queue processing may be blocked. Delete sending/.sender.lock manually.',
761
+ });
762
+ }
763
+
764
+ return warnings;
765
+ }
766
+
524
767
  // ---------------------------------------------------------------------------
525
768
  // Report file generation
526
769
  // ---------------------------------------------------------------------------
527
770
 
528
- function buildReport(results, timestamp) {
771
+ function buildReport(results, timestamp, warnings = []) {
529
772
  const lines = [
530
773
  `AI Lens Status Report`,
531
774
  `Generated: ${timestamp}`,
@@ -544,6 +787,17 @@ function buildReport(results, timestamp) {
544
787
  lines.push('');
545
788
  }
546
789
 
790
+ // Warnings
791
+ if (warnings.length > 0) {
792
+ lines.push(`${'='.repeat(60)}`);
793
+ lines.push('Warnings:');
794
+ for (const w of warnings) {
795
+ lines.push(` \u26a0 ${w.msg}`);
796
+ lines.push(` \u2192 ${w.action}`);
797
+ }
798
+ lines.push('');
799
+ }
800
+
547
801
  // Full config (masked)
548
802
  lines.push(`${'='.repeat(60)}`);
549
803
  lines.push('Full config (~/.ai-lens/config.json):');
@@ -706,34 +960,64 @@ export default async function status() {
706
960
  }
707
961
 
708
962
  // 7. Queue (before capture test so test event doesn't show as pending)
709
- printLine('Queue', checkQueue());
963
+ const queueResult = checkQueue();
964
+ printLine('Queue', queueResult);
710
965
 
711
- // 6b. Smoke-test the hook command
966
+ // 8. Smoke-test the hook command
712
967
  printLine('Capture test', checkCaptureRun(installedTools));
713
968
 
714
- // 8. Sender log
969
+ // 9. Sender log
715
970
  printLine('Sender log', checkSenderLog());
716
971
 
717
- // 8b. Capture drops
972
+ // 10. Capture drops
718
973
  printLine('Capture drops', checkCaptureLog());
719
974
 
720
- // 9. Server connectivity
975
+ // 11. Real activity
976
+ const realActivityResult = checkRealActivity();
977
+ printLine('Real activity', realActivityResult);
978
+
979
+ // 12. Server connectivity
721
980
  const serverUrl = configResult.serverUrl || readLensConfig().serverUrl;
722
981
  const serverResult = await checkServer(serverUrl);
723
982
  printLine('Server', serverResult);
724
983
 
725
- // 10. Token validity
984
+ // 13. Token validity
726
985
  const authToken = configResult.authToken || readLensConfig().authToken;
727
986
  const tokenResult = await checkToken(serverUrl, authToken);
728
987
  printLine('Token', tokenResult);
729
988
 
730
- // 11. E2E connectivity test
989
+ // 14. Server sessions
990
+ const serverSessionsResult = await checkServerSessions(serverUrl, authToken);
991
+ printLine('Server sessions', serverSessionsResult);
992
+
993
+ // 15. E2E connectivity test
731
994
  const e2eResult = await checkE2e(serverUrl, authToken);
732
995
  printLine('E2E test', e2eResult);
733
996
 
997
+ // 16. Summary warnings
998
+ const warnings = buildWarnings(results, {
999
+ hasEverCaptured: realActivityResult.hasEverCaptured,
1000
+ lastCaptureMs: realActivityResult.lastCaptureMs,
1001
+ serverSessionCount: serverSessionsResult.sessionCount,
1002
+ queuePending: queueResult.lineCount,
1003
+ lockStatus: queueResult.lockStatus,
1004
+ serverReachable: serverResult.ok === true,
1005
+ });
1006
+
1007
+ if (warnings.length > 0) {
1008
+ blank();
1009
+ info('='.repeat(40));
1010
+ info(`${BOLD}Warnings${RESET}`);
1011
+ blank();
1012
+ for (const w of warnings) {
1013
+ info(`${TRIANGLE} ${w.msg}`);
1014
+ info(` \u2192 ${w.action}`);
1015
+ }
1016
+ }
1017
+
734
1018
  // Write report file
735
1019
  const timestamp = new Date().toISOString();
736
- const report = buildReport(results, timestamp);
1020
+ const report = buildReport(results, timestamp, warnings);
737
1021
  try {
738
1022
  writeFileSync(REPORT_PATH, report);
739
1023
  blank();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-lens",
3
- "version": "0.8.20",
3
+ "version": "0.8.22",
4
4
  "type": "module",
5
5
  "description": "Centralized session analytics for AI coding tools",
6
6
  "bin": {