ai-lens 0.8.80 → 0.8.84

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
- a6ad00e
1
+ 8a405c3
package/CHANGELOG.md CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  History of changes to the `ai-lens` CLI package on npm. New entries go on top. Format: `## X.Y.Z — YYYY-MM-DD`, followed by user-facing bullets.
4
4
 
5
+ ## 0.8.84 — 2026-06-08
6
+ - fix: `ai-lens init` no longer wipes your saved auth token when a re-authentication is started but not finished — a closed browser, a timeout, or a transient server hiccup that briefly mis-flags a valid token. Previously this left `authToken: null` in your config and silently stopped all event capture until someone noticed; now the existing token is kept until a new one is actually obtained. If a server that requires auth does end up without a token, init now prints a loud "capture is OFF" error instead of a quiet note.
7
+
8
+ ## 0.8.83 — 2026-06-05
9
+ - fix: on Windows, a Claude Code project hook written with the macOS/Linux `$CLAUDE_PROJECT_DIR` syntax silently captures nothing (Windows needs `%CLAUDE_PROJECT_DIR%`). `ai-lens init` now detects this for per-machine (gitignored) hook files and rewrites it to the running OS's form, and `ai-lens status` flags it instead of showing it as fine. Committed shared hook files are left untouched.
10
+
11
+ ## 0.8.82 — 2026-06-05
12
+ - feat: `ai-lens init` now offers to import your local Claude Code history right away, so a fresh dashboard isn't empty — `npx -y ai-lens init --server <url>` sets up and (on confirm) imports in one step. Use `--no-import` to skip or `--import` to force.
13
+ - fix: `ai-lens import claude-code` works on a self-host instance without login (personal mode) — it ships via your git identity, the same way live capture does, instead of requiring a token.
14
+
15
+ ## 0.8.81 — 2026-06-05
16
+ - improve: `ai-lens status` now tells a stale error from an active one — a past send failure no longer shows red once sending has recovered, and the time and code of the last error are shown (e.g. "recovered — last error 1d ago (ECONNRESET)").
17
+ - improve: `ai-lens status` retries the server health check a few times before reporting "unreachable", so a one-off network blip no longer flags the server as down.
18
+ - improve: `ai-lens status --report` now includes your config and hook files (Cursor / Claude Code / Codex, both global and project scope), with the auth token masked, so setup issues can be diagnosed remotely without sending your local status file.
19
+
5
20
  ## 0.8.80 — 2026-06-04
6
21
  - fix: `ai-lens import claude-code` now caps long prompts (so a huge pasted prompt is no longer dropped by the server), captures prompts from messages that mix text with images/documents (shown as a safe `[N attachment(s)]` placeholder — never the image data), and avoids a stale session "end" time on transcripts that are still being written.
7
22
 
package/cli/hooks.js CHANGED
@@ -2,6 +2,7 @@ import { existsSync, lstatSync, readFileSync, writeFileSync, copyFileSync, renam
2
2
  import { join, dirname } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
4
  import { fileURLToPath } from 'node:url';
5
+ import { execFileSync } from 'node:child_process';
5
6
 
6
7
  const __dirname = dirname(fileURLToPath(import.meta.url));
7
8
  const PKG_ROOT = join(__dirname, '..');
@@ -934,20 +935,59 @@ function isAcceptableHookCommand(cmd) {
934
935
  return isGuiSafeHookCommand(cmd) || isClaudeProjectDirCommand(cmd);
935
936
  }
936
937
 
937
- function isCurrentAiLensHook(entry, expected) {
938
+ // True when an ai-lens $CLAUDE_PROJECT_DIR / %CLAUDE_PROJECT_DIR% hook is written
939
+ // with the OTHER platform's variable syntax — `$VAR` on Windows (cmd.exe wants
940
+ // `%VAR%`) or `%VAR%` on POSIX (sh wants `$VAR`). Claude Code hands the command to
941
+ // the native OS shell, so a wrong-syntax var never expands and the hook silently
942
+ // fails (Anthropic claude-code#24710). Used to flag such a PER-MACHINE hook as
943
+ // outdated so init rewrites it to the running OS's form. Committed hooks are
944
+ // protected separately — see analyzeToolHooks / allowPlatformRewrite.
945
+ export function isWrongPlatformProjectDirCommand(cmd, platform = process.platform) {
946
+ if (!isClaudeProjectDirCommand(cmd)) return false;
947
+ const n = (cmd || '').replace(/\\/g, '/');
948
+ const correctVar = platform === 'win32' ? '%CLAUDE_PROJECT_DIR%' : '$CLAUDE_PROJECT_DIR';
949
+ return !n.includes(correctVar);
950
+ }
951
+
952
+ // Whether a hook-config file is committed (tracked) in git. The anti-churn rule
953
+ // (treat both $ and % CLAUDE_PROJECT_DIR forms as current) exists ONLY to keep a
954
+ // COMMITTED cross-platform hook file from being flipped to one OS's syntax and
955
+ // re-committed, breaking teammates on the other OS. For per-machine files
956
+ // (untracked / gitignored — the supported model), there's nothing to protect, so
957
+ // init may rewrite a wrong-OS form to the running platform. Returns false on any
958
+ // error (git missing, not a repo, untracked) → treat as per-machine, free to rewrite.
959
+ function isGitTracked(filePath) {
960
+ try {
961
+ execFileSync('git', ['-C', dirname(filePath), 'ls-files', '--error-unmatch', '--', filePath], {
962
+ stdio: 'ignore',
963
+ });
964
+ return true;
965
+ } catch {
966
+ return false;
967
+ }
968
+ }
969
+
970
+ function isCurrentAiLensHook(entry, expected, opts = {}) {
938
971
  // "Current" = a GUI-safe install (launcher OR absolute-node capture.js) OR a
939
972
  // committed Claude Code $CLAUDE_PROJECT_DIR project hook. We do NOT require an exact
940
973
  // match against the expected command form — every valid install method captures
941
974
  // reliably, so none should be reported outdated. Only PATH-dependent forms
942
975
  // (bare `node`, `/usr/bin/env node`) WITHOUT $CLAUDE_PROJECT_DIR are outdated.
976
+ //
977
+ // Exception (allowPlatformRewrite, set for untracked/per-machine files): a
978
+ // $CLAUDE_PROJECT_DIR/%CLAUDE_PROJECT_DIR% hook written for the OTHER OS won't
979
+ // expand on this platform, so flag it outdated to let init rewrite it.
980
+ const { platform = process.platform, allowPlatformRewrite = false } = opts;
981
+ const ok = (cmd) => isAcceptableHookCommand(cmd)
982
+ && !(allowPlatformRewrite && isWrongPlatformProjectDirCommand(cmd, platform));
943
983
  // Flat format (Cursor): single command per entry.
944
984
  if (entry?.command != null) {
945
- return isAcceptableHookCommand(entry.command);
985
+ return ok(entry.command);
946
986
  }
947
987
  // Nested format (Claude Code / Codex): { matcher, hooks: [{ command }] }
948
988
  if (Array.isArray(entry?.hooks)) {
949
989
  if (expected?.matcher !== undefined && entry.matcher !== expected.matcher) return false;
950
- return entry.hooks.some(h => isAcceptableHookCommand(h?.command || ''));
990
+ return entry.hooks.some(h => ok(h?.command || ''));
951
991
  }
952
992
  return false;
953
993
  }
@@ -970,7 +1010,7 @@ export function detectInstalledTools(ctx = null) {
970
1010
  * Returns { status, config?, error? }
971
1011
  * status: 'fresh' | 'current' | 'outdated' | 'absent' | 'malformed'
972
1012
  */
973
- export function analyzeToolHooks(tool) {
1013
+ export function analyzeToolHooks(tool, opts = {}) {
974
1014
  if (!existsSync(tool.configPath)) {
975
1015
  return { status: 'fresh', disableAllHooks: false };
976
1016
  }
@@ -1002,6 +1042,12 @@ export function analyzeToolHooks(tool) {
1002
1042
  return { status: 'absent', config, disableAllHooks };
1003
1043
  }
1004
1044
 
1045
+ // Per-machine (untracked/gitignored) hook files may be rewritten to the running
1046
+ // OS's CLAUDE_PROJECT_DIR syntax; committed (tracked) files are protected from
1047
+ // churn. Caller may override both (tests, or callers with known context).
1048
+ const platform = opts.platform ?? process.platform;
1049
+ const allowPlatformRewrite = opts.allowPlatformRewrite ?? !isGitTracked(tool.configPath);
1050
+
1005
1051
  // Check if any AI Lens hooks exist
1006
1052
  let hasAiLens = false;
1007
1053
  let allCurrent = true;
@@ -1013,7 +1059,7 @@ export function analyzeToolHooks(tool) {
1013
1059
  for (const entry of entries) {
1014
1060
  if (isAiLensHook(entry)) {
1015
1061
  hasAiLens = true;
1016
- if (!isCurrentAiLensHook(entry, expected)) {
1062
+ if (!isCurrentAiLensHook(entry, expected, { platform, allowPlatformRewrite })) {
1017
1063
  allCurrent = false;
1018
1064
  }
1019
1065
  }
@@ -94,13 +94,19 @@ export async function fetchCoverage(sessionIds, { fetchImpl = globalThis.fetch }
94
94
  if (!sessionIds.length || !fetchImpl) return out;
95
95
  const base = getServerUrl();
96
96
  const token = getAuthToken();
97
+ // Auth: token preferred, else git-identity headers so coverage also works in
98
+ // self-host personal mode (no token) — same fallback the ingest path uses.
99
+ const ident = token ? null : getGitIdentity(process.cwd());
100
+ const authHeaders = token
101
+ ? { 'X-Auth-Token': token }
102
+ : (ident?.email ? { 'X-Developer-Git-Email': ident.email, ...(ident.name ? { 'X-Developer-Name': ident.name } : {}) } : {});
97
103
  const CHUNK = 400;
98
104
  for (let i = 0; i < sessionIds.length; i += CHUNK) {
99
105
  const batch = sessionIds.slice(i, i + CHUNK);
100
106
  try {
101
107
  const res = await fetchImpl(new URL('/api/events/coverage', base), {
102
108
  method: 'POST',
103
- headers: { 'Content-Type': 'application/json', ...(token ? { 'X-Auth-Token': token } : {}) },
109
+ headers: { 'Content-Type': 'application/json', ...authHeaders },
104
110
  body: JSON.stringify({ session_ids: batch }),
105
111
  });
106
112
  if (res.ok) Object.assign(out, await res.json());
@@ -109,6 +115,15 @@ export async function fetchCoverage(sessionIds, { fetchImpl = globalThis.fetch }
109
115
  return out;
110
116
  }
111
117
 
118
+ /**
119
+ * Can the importer actually deliver events? A personal auth token OR a resolvable
120
+ * git email (the ingest path accepts either). Pure-ish; takes the two resolvers so
121
+ * it's testable without touching real git/env.
122
+ */
123
+ export function hasDeliverableIdentity(token, gitEmail) {
124
+ return !!token || !!gitEmail;
125
+ }
126
+
112
127
  /**
113
128
  * Drop events at/after the live-coverage boundary for their session, so import
114
129
  * only adds the pre-live backlog. No boundary ⇒ keep all. (Pure, for tests.)
@@ -209,8 +224,10 @@ export default async function importClaudeCode(flags) {
209
224
  warn(`No Claude Code history found at ${PROJECTS_DIR}`);
210
225
  return;
211
226
  }
212
- if (!dryRun && !getAuthToken()) {
213
- error('No auth token. Run `npx ai-lens init` first (or set AI_LENS_AUTH_TOKEN).');
227
+ // Need a way to attribute events: a token, OR a git email (self-host personal
228
+ // mode ships via the git-identity fallback, just like live capture).
229
+ if (!dryRun && !hasDeliverableIdentity(getAuthToken(), getGitIdentity(process.cwd())?.email)) {
230
+ error('No identity to attribute events. Run `npx ai-lens init` first, or set a git user.email (or AI_LENS_AUTH_TOKEN).');
214
231
  return;
215
232
  }
216
233
  if (noRedact) warn('⚠ --no-redact: secrets in raw transcripts are NOT redacted client-side and persist locally in the spool if a send fails. Use only for debugging.');
package/cli/init.js CHANGED
@@ -361,6 +361,12 @@ function getInitArgs() {
361
361
  case '--install-launcher':
362
362
  flags.installLauncher = true;
363
363
  break;
364
+ case '--import':
365
+ flags.importHistory = true;
366
+ break;
367
+ case '--no-import':
368
+ flags.noImport = true;
369
+ break;
364
370
  case '--mcp-scope':
365
371
  if (i + 1 < args.length) flags.mcpScope = args[++i];
366
372
  else process.stderr.write('Warning: --mcp-scope requires a value\n');
@@ -628,29 +634,35 @@ export default async function init() {
628
634
 
629
635
  // Authentication
630
636
  heading('Authentication');
637
+ // Never null an existing token up front. An abandoned device-code login — or a
638
+ // transient server hiccup that mis-flags a good token (e.g. during a deploy) —
639
+ // must not leave the user with authToken:null, which silently breaks capture
640
+ // until someone notices. newConfig already inherits the prior token via the
641
+ // spread above; we only OVERWRITE it once we hold a confirmed new token.
642
+ let needsAuth = !currentConfig.authToken;
643
+ let authConfigured = true; // flipped false only if the server reports no Auth0
631
644
  if (currentConfig.authToken) {
632
645
  const tokenStatus = await validateExistingToken(serverUrl, currentConfig.authToken);
633
646
  if (tokenStatus === 'valid') {
634
647
  success(' Already authenticated (token verified)');
635
648
  } else if (tokenStatus === 'unknown') {
636
649
  warn(' Token format not recognized — re-authenticating...');
637
- currentConfig.authToken = null;
638
- newConfig.authToken = null;
650
+ needsAuth = true;
639
651
  } else if (tokenStatus === 'invalid') {
640
652
  warn(' Existing token is invalid or revoked — re-authenticating...');
641
- currentConfig.authToken = null;
642
- newConfig.authToken = null;
653
+ needsAuth = true;
643
654
  } else {
644
655
  warn(' Could not reach server to verify token — keeping existing token');
645
656
  }
646
657
  }
647
- if (!currentConfig.authToken) {
658
+ if (needsAuth) {
648
659
  let authResult = null;
649
660
  try {
650
661
  authResult = await deviceCodeAuth(serverUrl);
651
662
  } catch (err) {
652
663
  const msg = (err && err.message) ? err.message : String(err);
653
664
  if (msg.includes('not configured')) {
665
+ authConfigured = false;
654
666
  warn(` Auth not configured on server — personal mode (events sent via git identity)`);
655
667
  } else {
656
668
  warn(` Authentication failed: ${msg}`);
@@ -663,17 +675,29 @@ export default async function init() {
663
675
  saveLensConfig(newConfig);
664
676
  success(` Authenticated as ${authResult.name} (${authResult.email})`);
665
677
  } catch (err) {
678
+ // Keep whatever token newConfig already holds — do NOT null it.
666
679
  warn(` Authenticated but failed to save token: ${err.message}`);
667
680
  warn(` Run "npx -y ai-lens init" again later to persist authentication`);
668
- newConfig.authToken = null;
669
681
  }
682
+ } else if (currentConfig.authToken) {
683
+ // Re-auth didn't complete. Preserve the prior token rather than stranding
684
+ // the user tokenless: a transient false-negative keeps capture working, and
685
+ // a genuinely revoked token simply re-prompts on the next init.
686
+ warn(' Re-authentication not completed — keeping the existing token for now.');
670
687
  }
671
688
  }
672
689
 
673
- // Validate identity: no token + no git email = events will be dropped
690
+ // Loud failure when capture is actually broken: a server with Auth0 drops every
691
+ // event without a token (the git-email header path is personal-mode only), so a
692
+ // missing token here is not a soft warning — it's a silent data outage.
674
693
  if (!newConfig.authToken) {
675
694
  const { email } = getGitIdentity();
676
- if (!email) {
695
+ if (authConfigured) {
696
+ blank();
697
+ error(' No auth token — this server requires one, so EVERY event will be dropped.');
698
+ error(' Capture is OFF until you authenticate.');
699
+ info(' Fix: run "npx -y ai-lens init" and finish the browser login.');
700
+ } else if (!email) {
677
701
  blank();
678
702
  error(' No auth token and no git email configured.');
679
703
  error(' Events will be silently dropped until one is available.');
@@ -1194,5 +1218,42 @@ export default async function init() {
1194
1218
  }
1195
1219
  blank();
1196
1220
 
1221
+ // Primary warm-start trigger: offer to import local Claude Code history right now,
1222
+ // so the dashboard isn't empty on first open.
1223
+ await maybeOfferImportHistory(flags);
1224
+
1197
1225
  detail(`Log: ${getLogPath()}`);
1198
1226
  }
1227
+
1228
+ /**
1229
+ * After a successful init, offer to import the developer's local Claude Code
1230
+ * history. `--no-import` skips; `--import` or `--yes` runs it without prompting;
1231
+ * otherwise asks interactively. No-op if there's no `~/.claude/projects`.
1232
+ */
1233
+ async function maybeOfferImportHistory(flags) {
1234
+ if (flags.noImport) return;
1235
+ if (!existsSync(join(homedir(), '.claude', 'projects'))) return;
1236
+
1237
+ let run = flags.importHistory || flags.yes;
1238
+ if (!run) {
1239
+ try {
1240
+ const answer = (await ask('Import your local Claude Code history now? (Y/n) ')).toLowerCase();
1241
+ run = answer === '' || answer === 'y' || answer === 'yes';
1242
+ } catch { run = false; }
1243
+ }
1244
+ if (!run) {
1245
+ blank();
1246
+ info(' Skipped import. Run `npx -y ai-lens import claude-code` anytime to bring it in.');
1247
+ return;
1248
+ }
1249
+
1250
+ blank();
1251
+ heading('Importing local history');
1252
+ try {
1253
+ const { default: importClaudeCode } = await import('./import/claude-code.js');
1254
+ await importClaudeCode({ days: 30 }); // server URL + git identity were just configured
1255
+ } catch (err) {
1256
+ error(` Import failed: ${err.message}`);
1257
+ info(' You can retry later: `npx -y ai-lens import claude-code`');
1258
+ }
1259
+ }
package/cli/status.js CHANGED
@@ -619,40 +619,69 @@ function checkSenderLog() {
619
619
  return { ok: false, summary: `error reading log: ${err.message}`, detail: `Error: ${err.message}` };
620
620
  }
621
621
 
622
- // Aggregate across all log entries for observability
622
+ // Aggregate across all log entries for observability. Counts are all-time, but
623
+ // we also track recency (last error ts + a 24h window) so a single old failure
624
+ // doesn't flag the check red forever — see `recovered` below.
623
625
  let sentOk = 0; // total events successfully delivered to server
624
626
  let failedCount = 0; // total failed send attempts (connection/auth errors)
627
+ let recentFailed = 0; // failed sends within the last 24h
625
628
  let rollbackCount = 0; // total rollbacks (partial + full) — events re-queued for retry
626
629
  let lastSend = null;
630
+ let lastSendMs = 0;
631
+ let lastError = null; // ts of the most recent failure/error
632
+ let lastErrorMs = 0;
633
+ let lastErrorCode = null; // connection/error code of that last failure
627
634
  let hasErrors = false;
635
+ const DAY_MS = 24 * 60 * 60 * 1000;
636
+ const now = Date.now();
628
637
 
629
638
  for (const line of lines) {
630
639
  try {
631
640
  const entry = JSON.parse(line);
641
+ const tsMs = entry.ts ? new Date(entry.ts).getTime() : NaN;
632
642
  if (entry.msg === 'sent') {
633
643
  lastSend = entry.ts;
644
+ if (!isNaN(tsMs) && tsMs > lastSendMs) lastSendMs = tsMs;
634
645
  sentOk += parseInt(entry.events, 10) || 0;
635
646
  }
636
647
  if (entry.msg === 'failed' || entry.msg === 'error' || entry.msg === 'auth-failed') {
637
648
  hasErrors = true;
638
649
  if (entry.msg === 'failed') failedCount++;
650
+ if (!isNaN(tsMs)) {
651
+ if (tsMs > lastErrorMs) {
652
+ lastErrorMs = tsMs;
653
+ lastError = entry.ts;
654
+ lastErrorCode = entry.cause?.code || entry.code || entry.error || null;
655
+ }
656
+ if (entry.msg === 'failed' && now - tsMs <= DAY_MS) recentFailed++;
657
+ }
639
658
  }
640
659
  if (entry.msg === 'rollback' || entry.msg === 'partial-rollback') rollbackCount++;
641
660
  } catch { /* non-JSON line */ }
642
661
  }
643
662
 
663
+ // Recovered = the last successful send is newer than the last error, so the
664
+ // failures are historical noise rather than an active problem. Only treat
665
+ // errors as active (red) when nothing has succeeded since the last one.
666
+ const recovered = lastErrorMs > 0 && lastSendMs > lastErrorMs;
667
+ const activeErrors = hasErrors && !recovered;
668
+
644
669
  const stats = [];
645
670
  if (sentOk > 0) stats.push(`${sentOk} events sent`);
646
- if (failedCount > 0) stats.push(`${failedCount} failed`);
671
+ if (failedCount > 0) stats.push(`${failedCount} failed${recentFailed > 0 ? `, ${recentFailed} in 24h` : ''}`);
647
672
  if (rollbackCount > 0) stats.push(`${rollbackCount} rollbacks`);
648
673
 
674
+ const errNote = lastError
675
+ ? `last error ${relativeTime(lastError)}${lastErrorCode ? ` (${lastErrorCode})` : ''}`
676
+ : 'has errors';
677
+
649
678
  let summary;
650
679
  if (lastSend) {
651
680
  summary = `last send ${relativeTime(lastSend)}`;
652
681
  if (stats.length > 0) summary += ` (${stats.join(', ')})`;
653
- if (hasErrors) summary += ', has errors';
682
+ if (hasErrors) summary += recovered ? `, recovered — ${errNote}` : `, ${errNote}`;
654
683
  } else if (hasErrors) {
655
- summary = 'errors in log';
684
+ summary = `errors in log — ${errNote}`;
656
685
  if (stats.length > 0) summary += ` (${stats.join(', ')})`;
657
686
  } else {
658
687
  summary = `${lines.length} entries`;
@@ -660,10 +689,11 @@ function checkSenderLog() {
660
689
  }
661
690
 
662
691
  const last20 = lines.slice(-20);
692
+ const state = activeErrors ? 'ACTIVE ERRORS' : recovered ? 'recovered' : 'healthy';
663
693
  return {
664
- ok: !hasErrors,
694
+ ok: !activeErrors,
665
695
  summary,
666
- detail: `Log: ${LOG_PATH}\nTotal entries: ${lines.length}\nCounters: sent_ok=${sentOk}, failed=${failedCount}, rollbacks=${rollbackCount}\nLast 20 entries:\n${last20.join('\n')}`,
696
+ detail: `Log: ${LOG_PATH}\nTotal entries: ${lines.length}\nCounters: sent_ok=${sentOk}, failed=${failedCount} (${recentFailed} in 24h), rollbacks=${rollbackCount}\nLast send: ${lastSend || '(never)'}\nLast error: ${lastError || '(none)'}${lastErrorCode ? ` (${lastErrorCode})` : ''}\nState: ${state}\nLast 20 entries:\n${last20.join('\n')}`,
667
697
  };
668
698
  }
669
699
 
@@ -816,31 +846,46 @@ async function checkServer(serverUrl) {
816
846
  }
817
847
 
818
848
  const url = `${serverUrl}/api/health`;
819
- const start = Date.now();
820
- try {
821
- const res = await fetch(url, { signal: AbortSignal.timeout(8000) });
822
- const latency = Date.now() - start;
823
- const body = await res.text();
824
- if (res.ok) {
849
+ // A single probe times out on a transient network blip (VPN reconnect, brief
850
+ // packet loss) and flags the server red even when it's actually fine. Retry a
851
+ // few times with a short backoff before the verdict; an HTTP response (even an
852
+ // error status) short-circuits — the server answered, no point retrying.
853
+ const ATTEMPTS = 3;
854
+ const PER_ATTEMPT_TIMEOUT = 5000;
855
+ const codes = [];
856
+ const attemptLog = [];
857
+ for (let attempt = 1; attempt <= ATTEMPTS; attempt++) {
858
+ const start = Date.now();
859
+ try {
860
+ const res = await fetch(url, { signal: AbortSignal.timeout(PER_ATTEMPT_TIMEOUT) });
861
+ const latency = Date.now() - start;
862
+ const body = await res.text();
863
+ if (res.ok) {
864
+ const note = attempt > 1 ? `, ${attempt}/${ATTEMPTS} attempts` : '';
865
+ return {
866
+ ok: true,
867
+ summary: `reachable (${latency}ms${note})`,
868
+ detail: `URL: ${url}\nStatus: ${res.status}\nLatency: ${latency}ms\nAttempt: ${attempt}/${ATTEMPTS}\nResponse: ${body}`,
869
+ };
870
+ }
825
871
  return {
826
- ok: true,
827
- summary: `reachable (${latency}ms)`,
872
+ ok: false,
873
+ summary: `HTTP ${res.status} (${latency}ms)`,
828
874
  detail: `URL: ${url}\nStatus: ${res.status}\nLatency: ${latency}ms\nResponse: ${body}`,
829
875
  };
876
+ } catch (err) {
877
+ const latency = Date.now() - start;
878
+ const code = err.code || err.cause?.code || err.message;
879
+ codes.push(code);
880
+ attemptLog.push(`#${attempt}: ${code} (${latency}ms)`);
881
+ if (attempt < ATTEMPTS) await new Promise(r => setTimeout(r, 1000));
830
882
  }
831
- return {
832
- ok: false,
833
- summary: `HTTP ${res.status} (${latency}ms)`,
834
- detail: `URL: ${url}\nStatus: ${res.status}\nLatency: ${latency}ms\nResponse: ${body}`,
835
- };
836
- } catch (err) {
837
- const latency = Date.now() - start;
838
- return {
839
- ok: false,
840
- summary: `unreachable (${err.code || err.cause?.code || err.message})`,
841
- detail: `URL: ${url}\nError: ${err.message}\nLatency: ${latency}ms`,
842
- };
843
883
  }
884
+ return {
885
+ ok: false,
886
+ summary: `unreachable (${codes[codes.length - 1]}, ${ATTEMPTS} attempts)`,
887
+ detail: `URL: ${url}\nUnreachable after ${ATTEMPTS} attempts:\n${attemptLog.join('\n')}`,
888
+ };
844
889
  }
845
890
 
846
891
  async function checkToken(serverUrl, authToken) {
@@ -1097,7 +1142,50 @@ function buildReport(results, timestamp, warnings = [], allTools = TOOL_CONFIGS)
1097
1142
  // Report mode: POST structured status to server
1098
1143
  // ---------------------------------------------------------------------------
1099
1144
 
1100
- async function sendStatusReport(results, warnings, clientVersion, clientCommit, serverUrl, authToken) {
1145
+ // AI Lens config (~/.ai-lens/config.json) with the auth token masked, or null if
1146
+ // absent/unreadable. Mirrors the masked dump buildReport writes to the local file.
1147
+ function maskedAiLensConfig() {
1148
+ try {
1149
+ const raw = readFileSync(join(homedir(), '.ai-lens', 'config.json'), 'utf-8');
1150
+ const config = JSON.parse(raw);
1151
+ if (config.authToken) config.authToken = maskToken(config.authToken);
1152
+ return config;
1153
+ } catch {
1154
+ return null;
1155
+ }
1156
+ }
1157
+
1158
+ // Hook-config file contents for every tool (Cursor/Claude Code/Codex), both
1159
+ // global and project scope, so a single remote report is enough to diagnose hook
1160
+ // wiring (actual command form, scope, $CLAUDE_PROJECT_DIR vs %VAR%, missing file)
1161
+ // without asking the developer for their local ~/ai-lens-status.txt.
1162
+ function collectHookConfigs(allTools) {
1163
+ const entries = [];
1164
+ const readHooks = (tool, scope, path) => {
1165
+ let hooks = null, error = null;
1166
+ try {
1167
+ const parsed = JSON.parse(readFileSync(path, 'utf-8'));
1168
+ hooks = parsed.hooks ?? null;
1169
+ if (hooks === null) error = 'no hooks section';
1170
+ } catch (err) {
1171
+ error = err.code === 'ENOENT' ? 'not found' : err.message;
1172
+ }
1173
+ entries.push({ tool: tool.name, scope, path, hooks, error });
1174
+ };
1175
+ for (const tool of allTools) {
1176
+ const scope = TOOL_CONFIGS.includes(tool) ? 'global' : 'project';
1177
+ readHooks(tool, scope, tool.configPath);
1178
+ // Claude Code keeps a separate settings.local.json (personal/project override)
1179
+ // that can carry or disable hooks — capture it when present.
1180
+ if (tool.name.startsWith('Claude Code') && tool.dirPath) {
1181
+ const localPath = join(tool.dirPath, 'settings.local.json');
1182
+ if (existsSync(localPath)) readHooks(tool, `${scope}-local`, localPath);
1183
+ }
1184
+ }
1185
+ return entries;
1186
+ }
1187
+
1188
+ async function sendStatusReport(results, warnings, clientVersion, clientCommit, serverUrl, authToken, allTools = TOOL_CONFIGS) {
1101
1189
  if (!serverUrl || !authToken) return;
1102
1190
 
1103
1191
  const payload = {
@@ -1108,6 +1196,12 @@ async function sendStatusReport(results, warnings, clientVersion, clientCommit,
1108
1196
  os: `${process.platform} ${osRelease()} ${osArch()}`,
1109
1197
  checks: results.map(({ label, ok, summary, detail }) => ({ label, ok, summary, detail })),
1110
1198
  warnings: warnings.map(({ msg, action }) => ({ msg, action })),
1199
+ // Config + hook dumps (token masked) — the local-file-only sections, now also
1200
+ // sent so remote triage doesn't need the developer's ~/ai-lens-status.txt.
1201
+ configs: {
1202
+ ai_lens: maskedAiLensConfig(),
1203
+ hooks: collectHookConfigs(allTools),
1204
+ },
1111
1205
  };
1112
1206
 
1113
1207
  try {
@@ -1332,16 +1426,18 @@ export default async function status({ report = false } = {}) {
1332
1426
  }
1333
1427
  }
1334
1428
 
1429
+ // Global TOOL_CONFIGS (always listed, even if not installed) + project tools.
1430
+ // Used by both report modes: the server JSON config dump and the local text file.
1431
+ const allToolsForReport = [...TOOL_CONFIGS, ...toolsWithProject.filter(t => !TOOL_CONFIGS.includes(t))];
1432
+
1335
1433
  if (report) {
1336
1434
  // --report mode: same on-screen output as normal status, but POST the
1337
1435
  // structured JSON to the server instead of writing the local text file.
1338
- await sendStatusReport(results, warnings, version, commit, serverUrl, authToken);
1436
+ await sendStatusReport(results, warnings, version, commit, serverUrl, authToken, allToolsForReport);
1339
1437
  blank();
1340
1438
  } else {
1341
1439
  // Normal mode: write text report file
1342
1440
  const timestamp = new Date().toISOString();
1343
- // Merge global TOOL_CONFIGS (always listed, even if not installed) with project tools
1344
- const allToolsForReport = [...TOOL_CONFIGS, ...toolsWithProject.filter(t => !TOOL_CONFIGS.includes(t))];
1345
1441
  const reportText = buildReport(results, timestamp, warnings, allToolsForReport);
1346
1442
  try {
1347
1443
  writeFileSync(REPORT_PATH, reportText);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-lens",
3
- "version": "0.8.80",
3
+ "version": "0.8.84",
4
4
  "type": "module",
5
5
  "description": "Centralized session analytics for AI coding tools",
6
6
  "bin": {