ai-lens 0.8.107 → 0.8.109

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
- af32330
1
+ 99dee20
package/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
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.109 — 2026-06-22
6
+ - fix: `init --yes` no longer captures every project or silently imports your local Claude history. It now scopes capture to the git root of the current folder (still `--projects all` for everything), and imports past history only when you pass `--import`.
7
+
8
+ ## 0.8.108 — 2026-06-22
9
+ - feat: `ai-lens init --mcp-only` registers just the MCP server in Claude Code and Cursor — for when you only need to read sessions from Claude, not capture hooks. It skips hook setup, authentication, and history import. The MCP scope now mirrors how AI Lens is installed: a local/project install registers the MCP at project scope, a global install at user scope (local wins if both are present); pass `--mcp-scope` to override.
10
+ - change: `init --project-hooks` now also registers the MCP at project scope (Claude `local` + the project `.cursor/mcp.json`) to match where it writes hooks, instead of always registering globally. Pass `--mcp-scope` to override.
11
+
5
12
  ## 0.8.107 — 2026-06-19
6
13
  - fix: Cyrillic / non-ASCII in Windows hook payloads is no longer corrupted (it was being dropped as malformed JSON). The windowless launcher now reads the payload as raw bytes straight off the process's stdin and never references PowerShell's `$input` — merely touching `$input` both pre-drains stdin and decodes it through the console's OEM codepage, mangling the UTF-8 BOM and every Cyrillic byte before forwarding. Byte-exact passthrough now; the 0.8.105 read still went through `$input` first. Cursor and Claude Code.
7
14
  - fix: the Windows hidden-sender launch now runs detached, so it isn't killed together with the hook's process tree before it finishes starting the sender — closing a rare window where an event could be dropped. The launcher is GUI-subsystem, so detached still adds no console window (no flash).
package/bin/ai-lens.js CHANGED
@@ -59,9 +59,10 @@ switch (command) {
59
59
  console.log(' --server URL Server URL (default: saved or http://localhost:3000)');
60
60
  console.log(' --yes, -y Non-interactive: accept all defaults, no prompts');
61
61
  console.log(' --projects LIST Comma-separated project paths to track');
62
- console.log(' --no-hooks Skip writing hooks and MCP (config + auth only)');
62
+ console.log(' --no-hooks Skip writing hooks (config + auth + MCP only)');
63
63
  console.log(' --no-mcp Skip MCP server registration');
64
- console.log(' --mcp-scope S MCP scope: user, local, or project (default: user)');
64
+ console.log(' --mcp-only Register only the MCP server, mirroring your install scope (skip hooks/auth/import)');
65
+ console.log(' --mcp-scope S MCP scope: user, local, or project (default: auto-detected from install scope, else user)');
65
66
  console.log(' --project-hooks Write Cursor/Claude hooks to project .cursor/ and .claude/ (not ~/.cursor)');
66
67
  console.log(' --use-repo-path Run capture.js from this package; skip copy to ~/.ai-lens/client/');
67
68
  console.log(' remove Remove AI Lens hooks and client files');
package/cli/hooks.js CHANGED
@@ -1190,6 +1190,50 @@ export function detectInstalledTools(ctx = null) {
1190
1190
  return tools.filter(t => existsSync(t.dirPath));
1191
1191
  }
1192
1192
 
1193
+ /**
1194
+ * Detect whether AI Lens is installed at the PROJECT (local) or GLOBAL (user)
1195
+ * level, by looking at where its hooks actually live. Used to mirror the install
1196
+ * scope when registering the MCP server (`init --mcp-only`).
1197
+ *
1198
+ * "present" = a tool config whose hooks include an AI Lens entry, i.e.
1199
+ * analyzeToolHooks(...).status is 'current' or 'outdated' (NOT 'fresh'/'absent').
1200
+ *
1201
+ * Project (local) takes priority over global when both are present.
1202
+ *
1203
+ * @param {string} projectRoot - Absolute path to inspect (e.g. process.cwd()).
1204
+ * @returns {'project' | 'user' | null}
1205
+ */
1206
+ export function detectAiLensInstallScope(projectRoot) {
1207
+ // noBackup: this is read-only scope detection — never rename/back up a malformed
1208
+ // file just to sniff its status (the project configs are sharedConfig:false, which
1209
+ // would otherwise trigger a .bak rename of the user's settings.local.json).
1210
+ const isPresent = (tool) => {
1211
+ if (!tool) return false;
1212
+ const status = analyzeToolHooks(tool, { noBackup: true }).status;
1213
+ return status === 'current' || status === 'outdated';
1214
+ };
1215
+
1216
+ // Project-level configs. Claude project hooks land in settings.local.json
1217
+ // (per-machine, gitignored); the committed `--use-repo-path` form lands in
1218
+ // settings.json — check BOTH so neither install style is missed.
1219
+ const claudeProject = getClaudeCodeToolConfig(projectRoot);
1220
+ const claudeProjectLocal = claudeProject
1221
+ ? { ...claudeProject, configPath: claudeLocalSettingsPath(claudeProject) }
1222
+ : null;
1223
+ const projectTools = [
1224
+ claudeProject,
1225
+ claudeProjectLocal,
1226
+ getCursorToolConfig(projectRoot),
1227
+ getCodexToolConfig(projectRoot),
1228
+ ];
1229
+ if (projectTools.some(isPresent)) return 'project';
1230
+
1231
+ // Global (user-level) configs — ~/.claude, ~/.cursor, ~/.codex.
1232
+ if (detectInstalledTools().some(isPresent)) return 'user';
1233
+
1234
+ return null;
1235
+ }
1236
+
1193
1237
  // ---------------------------------------------------------------------------
1194
1238
  // Analysis
1195
1239
  // ---------------------------------------------------------------------------
@@ -1215,8 +1259,10 @@ export function analyzeToolHooks(tool, opts = {}) {
1215
1259
  try {
1216
1260
  config = JSON.parse(raw);
1217
1261
  } catch (err) {
1218
- // For shared config files (settings.json), don't backup/rename — other tools depend on it
1219
- if (tool.sharedConfig) {
1262
+ // For shared config files (settings.json), don't backup/rename — other tools depend on it.
1263
+ // opts.noBackup lets read-only callers (e.g. install-scope detection) inspect status
1264
+ // without mutating the user's file.
1265
+ if (tool.sharedConfig || opts.noBackup) {
1220
1266
  return { status: 'malformed', error: err.message, disableAllHooks: false };
1221
1267
  }
1222
1268
  const bakPath = tool.configPath + '.bak';
@@ -1807,13 +1853,20 @@ function readJsonSafe(path) {
1807
1853
 
1808
1854
  /**
1809
1855
  * Add or update ai-lens MCP server in Cursor's mcp.json.
1856
+ * @param {string} mcpUrl
1857
+ * @param {object} [opts]
1858
+ * @param {string} [opts.projectRoot] - When set, write the project-level
1859
+ * <projectRoot>/.cursor/mcp.json instead of the global ~/.cursor/mcp.json.
1810
1860
  */
1811
- export function addCursorMcp(mcpUrl) {
1812
- const config = readJsonSafe(CURSOR_MCP_GLOBAL) || { mcpServers: {} };
1861
+ export function addCursorMcp(mcpUrl, { projectRoot } = {}) {
1862
+ const target = projectRoot
1863
+ ? join(projectRoot, '.cursor', 'mcp.json')
1864
+ : CURSOR_MCP_GLOBAL;
1865
+ const config = readJsonSafe(target) || { mcpServers: {} };
1813
1866
  if (!config.mcpServers) config.mcpServers = {};
1814
1867
  config.mcpServers['ai-lens'] = cursorMcpEntry(mcpUrl);
1815
- mkdirSync(dirname(CURSOR_MCP_GLOBAL), { recursive: true });
1816
- writeFileSync(CURSOR_MCP_GLOBAL, JSON.stringify(config, null, 2) + '\n');
1868
+ mkdirSync(dirname(target), { recursive: true });
1869
+ writeFileSync(target, JSON.stringify(config, null, 2) + '\n');
1817
1870
  }
1818
1871
 
1819
1872
  /**
package/cli/init.js CHANGED
@@ -20,9 +20,9 @@ import {
20
20
  getClaudeCodeHookDefsWithPath, getClaudeCodeHookDefsWithProjectDir, getCursorHookDefsWithPath, getCodexHookDefsWithPath,
21
21
  cleanupLegacyHooks, cleanupOppositeScope, cleanupEmptyMcpJson, addCursorMcp, removeCursorMcp,
22
22
  checkHooksDisabled, enableHooks,
23
- findStableNodePath,
23
+ findStableNodePath, detectAiLensInstallScope,
24
24
  } from './hooks.js';
25
- import { scanNestedClaudeProjects, summarizeNestedProjects } from './scan.js';
25
+ import { scanNestedProjects, summarizeNestedProjects } from './scan.js';
26
26
 
27
27
  function ask(question) {
28
28
  const rl = createInterface({ input: process.stdin, output: process.stdout });
@@ -35,6 +35,37 @@ function ask(question) {
35
35
  });
36
36
  }
37
37
 
38
+ // Default `projects` scope for `init --yes` on a FRESH config: the git root of
39
+ // cwd (the dev's work tree — the workspace when init is run there). Returns null
40
+ // when cwd isn't inside a git repo (e.g. bot containers under /app) → capture-all,
41
+ // which stays safe because the 90-day history import is separately gated to
42
+ // opt-in (importMode).
43
+ //
44
+ // Uses the INNERMOST git root (`git rev-parse --show-toplevel`), NOT an outermost
45
+ // walk-up: a `~/.git` dotfiles repo must never escalate the scope to $HOME (that
46
+ // would re-introduce capture-all + a personal-history import). Without this,
47
+ // `init --yes` left projects=null (= capture ALL).
48
+ export function defaultProjectsScope(cwd = process.cwd()) {
49
+ try {
50
+ const root = execSync('git rev-parse --show-toplevel', {
51
+ cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
52
+ }).trim();
53
+ return root || null;
54
+ } catch {
55
+ return null; // not a git repo (or git unavailable) → keep capture-all
56
+ }
57
+ }
58
+
59
+ // History-import decision for `init`. Opt-in by design: `--import` runs it,
60
+ // interactive prompts, and `--yes` SKIPS (it used to auto-run, which silently
61
+ // imported the full 90-day ~/.claude history including personal projects).
62
+ // `--no-import` is handled earlier (skips outright before the preview).
63
+ export function importMode(flags = {}) {
64
+ if (flags.importHistory) return 'run'; // explicit opt-in (highest priority)
65
+ if (flags.yes) return 'skip'; // non-interactive: never auto-import
66
+ return 'prompt'; // interactive: ask
67
+ }
68
+
38
69
  function getJson(url) {
39
70
  return new Promise((resolve, reject) => {
40
71
  const parsed = new URL(url);
@@ -193,6 +224,14 @@ function makeNestedClaudeTool(projectDir, capturePathInHooks, ctx = null) {
193
224
  return tool;
194
225
  }
195
226
 
227
+ function makeNestedCodexTool(projectDir, capturePathInHooks, ctx = null) {
228
+ const tool = getCodexToolConfig(projectDir, `Codex (${projectDir})`, ctx);
229
+ if (capturePathInHooks) {
230
+ tool.hookDefs = getCodexHookDefsWithPath(capturePathInHooks, ctx);
231
+ }
232
+ return tool;
233
+ }
234
+
196
235
  async function deviceCodeAuth(serverUrl) {
197
236
  // 1. Fetch Auth0 config from server
198
237
  let config;
@@ -353,6 +392,9 @@ function getInitArgs() {
353
392
  case '--no-mcp':
354
393
  flags.noMcp = true;
355
394
  break;
395
+ case '--mcp-only':
396
+ flags.mcpOnly = true;
397
+ break;
356
398
  case '--no-hooks':
357
399
  flags.noHooks = true;
358
400
  break;
@@ -379,7 +421,7 @@ function getInitArgs() {
379
421
  const a = args[i];
380
422
  if (a.startsWith('-')) {
381
423
  process.stderr.write(`Warning: unknown flag "${a}" — did you mean --${a.replace(/^-+/, '')}?\n`);
382
- } else if (['server', 'projects', 'yes', 'no-mcp', 'no-hooks', 'project-hooks', 'use-repo-path', 'install-launcher', 'mcp-scope'].includes(a)) {
424
+ } else if (['server', 'projects', 'yes', 'no-mcp', 'mcp-only', 'no-hooks', 'project-hooks', 'use-repo-path', 'install-launcher', 'mcp-scope'].includes(a)) {
383
425
  process.stderr.write(`Warning: unexpected argument "${a}" — did you mean --${a}?\n`);
384
426
  }
385
427
  }
@@ -389,6 +431,138 @@ function getInitArgs() {
389
431
  return flags;
390
432
  }
391
433
 
434
+ /**
435
+ * Resolve the server URL from --server flag / saved config / interactive prompt.
436
+ * Normalizes scheme + trailing slash and validates. Shared by the full init flow
437
+ * and the --mcp-only path.
438
+ */
439
+ async function resolveServerUrl(flags, auto, currentConfig) {
440
+ const currentServer = currentConfig.serverUrl || 'http://localhost:3000';
441
+ let serverUrl;
442
+ if (flags.server) {
443
+ serverUrl = flags.server.replace(/\/+$/, '');
444
+ } else if (auto) {
445
+ serverUrl = currentServer;
446
+ } else {
447
+ const serverInput = await ask(`Server URL (Enter = ${currentServer}): `);
448
+ serverUrl = (serverInput || currentServer).replace(/\/+$/, '');
449
+ }
450
+ if (!/^https?:\/\//i.test(serverUrl)) serverUrl = `http://${serverUrl}`;
451
+ try { new URL(serverUrl); } catch { error(`Invalid server URL: ${serverUrl}`); process.exit(1); }
452
+ return serverUrl;
453
+ }
454
+
455
+ /**
456
+ * Register the AI Lens MCP server in Claude Code and/or Cursor (HTTP transport —
457
+ * auth via OAuth in browser, no token needed). Shared by the full init flow and
458
+ * the --mcp-only path.
459
+ *
460
+ * @param {string} serverUrl
461
+ * @param {object} opts
462
+ * @param {boolean} opts.auto - non-interactive (--yes)
463
+ * @param {string} [opts.mcpScope] - explicit --mcp-scope (user|local|project)
464
+ * @param {string} [opts.forcedScope] - pre-resolved Claude scope; skips the prompt
465
+ * @param {string} [opts.projectRoot] - project dir to use when the scope is local/project
466
+ */
467
+ async function setupMcpServers(serverUrl, { auto, mcpScope, forcedScope = null, projectRoot = null }) {
468
+ const mcpUrl = `${serverUrl}/mcp`;
469
+
470
+ const claudeDir = join(homedir(), '.claude');
471
+ const hasClaudeDir = existsSync(claudeDir);
472
+ let hasClaudeCli = false;
473
+ try { execSync(process.platform === 'win32' ? 'where claude' : 'which claude', { stdio: 'ignore' }); hasClaudeCli = true; } catch {}
474
+
475
+ const cursorDir = join(homedir(), '.cursor');
476
+ const hasCursorDir = existsSync(cursorDir);
477
+
478
+ // A local/project scope targets the project .cursor/mcp.json; a user scope (or no
479
+ // project context) targets the global ~/.cursor/mcp.json. Owned here so the full
480
+ // init flow and --mcp-only map scope → Cursor target the same way.
481
+ const cursorRootFor = (scope) => ((scope === 'local' || scope === 'project') ? projectRoot : null);
482
+
483
+ // Resolve the Claude scope when it's known without prompting. null ⇒ ask
484
+ // interactively in the Claude block below, and the Cursor target then follows it.
485
+ let resolvedScope = forcedScope
486
+ || (mcpScope && ['local', 'project', 'user'].includes(mcpScope) ? mcpScope : null)
487
+ || (auto ? 'user' : null);
488
+
489
+ if (!hasClaudeDir && !hasCursorDir && !cursorRootFor(resolvedScope)) {
490
+ warn(' No MCP-capable tool found (~/.claude or ~/.cursor) — nothing to register.');
491
+ return;
492
+ }
493
+ if (hasClaudeDir && !hasClaudeCli) {
494
+ warn(' Claude Code detected but the `claude` CLI is not on PATH — skipping Claude MCP registration.');
495
+ }
496
+
497
+ // Claude Code MCP
498
+ if (hasClaudeDir && hasClaudeCli) {
499
+ heading('MCP Server — Claude Code');
500
+ let doSetup;
501
+ if (auto) {
502
+ doSetup = true;
503
+ } else {
504
+ const mcpAnswer = await ask('Set up AI Lens MCP server in Claude Code? [Y/n] ');
505
+ doSetup = !mcpAnswer || mcpAnswer.toLowerCase() === 'y';
506
+ }
507
+
508
+ if (doSetup) {
509
+ if (!resolvedScope) {
510
+ const scopeInput = await ask(' Scope — user, local, or project? (Enter = user): ');
511
+ resolvedScope = ['local', 'project', 'user'].includes(scopeInput) ? scopeInput : 'user';
512
+ }
513
+ const scope = resolvedScope;
514
+ try {
515
+ try { execSync('claude mcp remove ai-lens -s local', { stdio: 'ignore', shell: true }); } catch {}
516
+ try { execSync('claude mcp remove ai-lens -s project', { stdio: 'ignore', shell: true }); } catch {}
517
+ try { execSync('claude mcp remove ai-lens -s user', { stdio: 'ignore', shell: true }); } catch {}
518
+ cleanupEmptyMcpJson();
519
+ const escapedMcpUrl = process.platform === 'win32'
520
+ ? `"${mcpUrl.replace(/"/g, '""')}"`
521
+ : `'${mcpUrl.replace(/'/g, "'\\''")}'`;
522
+ execSync(
523
+ `claude mcp add --transport http ai-lens -s ${scope} ${escapedMcpUrl}`,
524
+ { stdio: 'inherit', shell: true },
525
+ );
526
+ success(` MCP server registered in Claude Code (${scope})`);
527
+ } catch (err) {
528
+ error(` Failed to register MCP server: ${err.message}`);
529
+ }
530
+ } else {
531
+ info(' Skipped');
532
+ }
533
+ }
534
+
535
+ // Cursor MCP. Mirror the resolved scope: project file for local/project, else global.
536
+ // Gate on a project target too, so a project-scoped install still registers even when
537
+ // the global ~/.cursor doesn't exist (fresh machine / CI / Cursor-only-in-project).
538
+ const cursorProjectRoot = cursorRootFor(resolvedScope);
539
+ if (hasCursorDir || cursorProjectRoot) {
540
+ heading('MCP Server — Cursor');
541
+ let doSetup;
542
+ if (auto) {
543
+ doSetup = true;
544
+ } else {
545
+ const cursorMcpAnswer = await ask('Set up AI Lens MCP server in Cursor? [Y/n] ');
546
+ doSetup = !cursorMcpAnswer || cursorMcpAnswer.toLowerCase() === 'y';
547
+ }
548
+
549
+ if (doSetup) {
550
+ try {
551
+ removeCursorMcp();
552
+ addCursorMcp(mcpUrl, { projectRoot: cursorProjectRoot });
553
+ const where = cursorProjectRoot
554
+ ? join(cursorProjectRoot, '.cursor', 'mcp.json')
555
+ : '~/.cursor/mcp.json';
556
+ success(` MCP server registered in Cursor (${where})`);
557
+ } catch (err) {
558
+ error(` Failed to register MCP server: ${err.message}`);
559
+ }
560
+ } else {
561
+ info(' Skipped');
562
+ }
563
+ }
564
+ }
565
+
392
566
  export default async function init() {
393
567
  const flags = getInitArgs();
394
568
  const auto = flags.yes || false;
@@ -400,6 +574,50 @@ export default async function init() {
400
574
  heading(`AI Lens — Init v${version} (${commit})`);
401
575
  detail(`capture.js: ${CAPTURE_PATH}`);
402
576
 
577
+ // --mcp-only: register ONLY the MCP server, mirroring the existing AI Lens
578
+ // install scope (local/project vs global/user). Skips hooks, auth, and import.
579
+ if (flags.mcpOnly) {
580
+ if (flags.noMcp) {
581
+ error('--mcp-only cannot be combined with --no-mcp.');
582
+ process.exit(1);
583
+ }
584
+ heading('Configuration');
585
+ const currentConfig = readLensConfig();
586
+ const serverUrl = await resolveServerUrl(flags, auto, currentConfig);
587
+ info(` Server: ${serverUrl}`);
588
+
589
+ // Soft health check — don't block registration (the server may be briefly down),
590
+ // but warn so a typo'd URL doesn't silently register a dead MCP (verification is skipped).
591
+ try {
592
+ const health = await getJson(`${serverUrl}/api/health`);
593
+ if (health.status !== 'ok') warn(` Server responded with unexpected status: ${JSON.stringify(health)}`);
594
+ } catch (err) {
595
+ warn(` Server unreachable (${err.message}) — registering MCP anyway; double-check the URL.`);
596
+ }
597
+
598
+ // Persist the server URL so it sticks for later runs.
599
+ saveLensConfig({ ...currentConfig, serverUrl });
600
+
601
+ // Scope precedence: explicit --mcp-scope > --project-hooks > detected footprint > user.
602
+ const cwd = resolve(process.cwd());
603
+ let forcedScope;
604
+ if (flags.mcpScope && ['user', 'local', 'project'].includes(flags.mcpScope)) {
605
+ forcedScope = flags.mcpScope;
606
+ } else if (flags.projectHooks) {
607
+ forcedScope = 'local';
608
+ } else {
609
+ const detected = detectAiLensInstallScope(cwd); // 'project' | 'user' | null
610
+ forcedScope = detected === 'project' ? 'local' : 'user';
611
+ }
612
+ info(` Install scope: ${forcedScope === 'user' ? 'global (user)' : 'local (project)'}`);
613
+
614
+ await setupMcpServers(serverUrl, { auto, forcedScope, projectRoot: cwd });
615
+
616
+ heading('Done');
617
+ info(' MCP-only setup complete — hooks, auth, and import were skipped.');
618
+ return;
619
+ }
620
+
403
621
  // Resolve a stable node binary path up-front. The same resolution is baked into
404
622
  // the per-machine launcher (run.sh / run.cmd) and into hook commands. We only
405
623
  // need it when hooks will actually be written; --no-hooks skips it so users
@@ -546,20 +764,7 @@ export default async function init() {
546
764
  const currentConfig = readLensConfig();
547
765
 
548
766
  // Server URL
549
- const currentServer = currentConfig.serverUrl || 'http://localhost:3000';
550
- let serverUrl;
551
- if (flags.server) {
552
- serverUrl = flags.server.replace(/\/+$/, '');
553
- } else if (auto) {
554
- serverUrl = currentServer;
555
- } else {
556
- const serverInput = await ask(
557
- `Server URL (Enter = ${currentServer}): `,
558
- );
559
- serverUrl = (serverInput || currentServer).replace(/\/+$/, '');
560
- }
561
- if (!/^https?:\/\//i.test(serverUrl)) serverUrl = `http://${serverUrl}`;
562
- try { new URL(serverUrl); } catch { error(`Invalid server URL: ${serverUrl}`); process.exit(1); }
767
+ const serverUrl = await resolveServerUrl(flags, auto, currentConfig);
563
768
  info(` Server: ${serverUrl}`);
564
769
 
565
770
  // Project filter
@@ -569,7 +774,11 @@ export default async function init() {
569
774
  if (flags.projects) {
570
775
  projects = flags.projects;
571
776
  } else if (auto) {
572
- projects = currentProjects || projectHooksDefault;
777
+ // Fresh config under --yes: default to the git root of cwd (the work tree),
778
+ // not null. null = capture ALL projects. Stays null only when cwd isn't a
779
+ // git repo (bots/containers) — safe because the import is separately gated.
780
+ // `--projects all` is the explicit opt-in for capture-everything.
781
+ projects = currentProjects || projectHooksDefault || defaultProjectsScope();
573
782
  } else {
574
783
  const projectsDefault = currentProjects || projectHooksDefault || 'all';
575
784
  const projectsInput = await ask(
@@ -756,34 +965,50 @@ export default async function init() {
756
965
 
757
966
  let nestedScan = [];
758
967
  let nestedPending = [];
968
+ let nestedCodexTrust = [];
759
969
  if (flags.projectHooks) {
760
970
  const trackedRoots = getTrackedRoots(newConfig.projects, resolve(process.cwd()));
761
- nestedScan = scanNestedClaudeProjects(trackedRoots);
971
+ nestedScan = scanNestedProjects(trackedRoots);
762
972
 
763
973
  if (nestedScan.length > 0) {
764
- heading('Nested Claude Code projects');
974
+ heading('Nested projects');
765
975
  for (const result of nestedScan) {
766
976
  const installNote = result.installTarget ? ` -> install in ${result.installTarget}` : '';
767
- info(` ${result.relativePath}: ${result.status}${installNote}`);
977
+ info(` ${result.relativePath} (${result.tool}): ${result.status}${installNote}`);
768
978
  }
769
979
  const summary = summarizeNestedProjects(nestedScan);
770
- detail(`Found ${summary.total} nested project(s), ${summary.unhooked} need attention.`);
980
+ detail(`Found ${summary.total} nested hook target(s), ${summary.unhooked} need attention.`);
771
981
  blank();
772
982
  }
773
983
 
774
984
  const nestedActionable = nestedScan.filter(result => result.installTarget);
985
+ nestedCodexTrust = nestedScan
986
+ .filter(result => result.tool === 'codex' && !result.installTarget)
987
+ .map(result => ({ tool: makeNestedCodexTool(result.projectDir, null, ctx) }));
775
988
  let shouldInstallNested = auto && nestedActionable.length > 0;
776
989
  if (!auto && nestedActionable.length > 0) {
777
- const answer = await ask(`Install hooks in ${nestedActionable.length} nested Claude Code project(s)? [Y/n] `);
990
+ const answer = await ask(`Install hooks in ${nestedActionable.length} nested hook target(s)? [Y/n] `);
778
991
  shouldInstallNested = !answer || ['y', 'yes'].includes(answer.toLowerCase());
779
992
  }
780
993
 
781
994
  if (shouldInstallNested) {
782
995
  nestedPending = nestedActionable.map(result => {
783
- const capturePathInHooks = flags.useRepoPath
784
- ? relative(result.projectDir, resolve(REPO_CAPTURE_PATH)).replace(/\\/g, '/')
785
- : null;
786
- const tool = makeNestedClaudeTool(result.projectDir, capturePathInHooks, ctx);
996
+ let tool;
997
+ if (result.tool === 'codex') {
998
+ const capturePathInHooks = flags.useRepoPath
999
+ ? (() => {
1000
+ const abs = resolve(REPO_CAPTURE_PATH);
1001
+ const userHome = homedir();
1002
+ return abs.startsWith(userHome) ? `~${abs.slice(userHome.length).replace(/\\/g, '/')}` : abs.replace(/\\/g, '/');
1003
+ })()
1004
+ : null;
1005
+ tool = makeNestedCodexTool(result.projectDir, capturePathInHooks, ctx);
1006
+ } else {
1007
+ const capturePathInHooks = flags.useRepoPath
1008
+ ? relative(result.projectDir, resolve(REPO_CAPTURE_PATH)).replace(/\\/g, '/')
1009
+ : null;
1010
+ tool = makeNestedClaudeTool(result.projectDir, capturePathInHooks, ctx);
1011
+ }
787
1012
  tool.configPath = join(tool.dirPath, result.installTarget);
788
1013
  return {
789
1014
  tool,
@@ -822,7 +1047,7 @@ export default async function init() {
822
1047
  // Idempotent: re-run hook trust enrollment even when no hooks needed
823
1048
  // rewriting. Without this, a previously-installed-but-untrusted Codex
824
1049
  // hook stays inert until the next time hooks themselves change.
825
- for (const { tool } of analyses) {
1050
+ for (const { tool } of [...analyses, ...nestedCodexTrust]) {
826
1051
  if (tool.name !== 'Codex' && !tool.name.startsWith('Codex')) continue;
827
1052
  try {
828
1053
  const r = enableCodexHookTrust(tool.configPath);
@@ -861,8 +1086,10 @@ export default async function init() {
861
1086
 
862
1087
  for (const { tool, analysis } of [...pending, ...nestedPending]) {
863
1088
  try {
864
- // Backup malformed shared configs (e.g. ~/.claude/settings.json) before overwriting
865
- if (analysis.status === 'malformed' && tool.sharedConfig) {
1089
+ // Backup malformed configs before overwriting. analyzeToolHooks already
1090
+ // renames non-shared malformed files; nested scan is read-only, so it
1091
+ // reaches this path and needs the backup here.
1092
+ if (analysis.status === 'malformed' && existsSync(tool.configPath)) {
866
1093
  try { copyFileSync(tool.configPath, tool.configPath + '.bak'); } catch { /* file may be gone */ }
867
1094
  }
868
1095
  const existingConfig = analysis.config || null;
@@ -882,7 +1109,7 @@ export default async function init() {
882
1109
  // key, no `file:` prefix) — the same state the TUI "Trust all" flow writes,
883
1110
  // but driven by AI Lens at install time. With it, `codex exec` fires hooks
884
1111
  // with no interactive step.
885
- for (const { tool } of [...pending, ...nestedPending]) {
1112
+ for (const { tool } of [...pending, ...nestedPending, ...nestedCodexTrust]) {
886
1113
  if (tool.name !== 'Codex' && !tool.name.startsWith('Codex')) continue;
887
1114
  try {
888
1115
  const r = enableCodexHookTrust(tool.configPath);
@@ -1074,81 +1301,17 @@ export default async function init() {
1074
1301
  } catch {}
1075
1302
  }
1076
1303
 
1077
- // MCP setup (HTTP transport — auth via OAuth in browser, no token needed)
1304
+ // MCP setup (HTTP transport — auth via OAuth in browser, no token needed).
1305
+ // --project-hooks installs hooks at project scope, so mirror that for the MCP:
1306
+ // default the scope to local and target the project .cursor/mcp.json (an explicit
1307
+ // --mcp-scope still wins; setupMcpServers maps scope → Cursor target).
1078
1308
  if (!flags.noMcp) {
1079
- const mcpUrl = `${serverUrl}/mcp`;
1080
-
1081
- // Claude Code MCP
1082
- const claudeDir = join(homedir(), '.claude');
1083
- const hasClaudeDir = existsSync(claudeDir);
1084
- let hasClaudeCli = false;
1085
- try { execSync(process.platform === 'win32' ? 'where claude' : 'which claude', { stdio: 'ignore' }); hasClaudeCli = true; } catch {}
1086
-
1087
- if (hasClaudeDir && hasClaudeCli) {
1088
- heading('MCP Server — Claude Code');
1089
- let doSetup;
1090
- if (auto) {
1091
- doSetup = true;
1092
- } else {
1093
- const mcpAnswer = await ask('Set up AI Lens MCP server in Claude Code? [Y/n] ');
1094
- doSetup = !mcpAnswer || mcpAnswer.toLowerCase() === 'y';
1095
- }
1096
-
1097
- if (doSetup) {
1098
- let scope;
1099
- if (flags.mcpScope && ['local', 'project', 'user'].includes(flags.mcpScope)) {
1100
- scope = flags.mcpScope;
1101
- } else if (auto) {
1102
- scope = 'user';
1103
- } else {
1104
- const scopeInput = await ask(' Scope — user, local, or project? (Enter = user): ');
1105
- scope = ['local', 'project', 'user'].includes(scopeInput) ? scopeInput : 'user';
1106
- }
1107
- try {
1108
- try { execSync('claude mcp remove ai-lens -s local', { stdio: 'ignore', shell: true }); } catch {}
1109
- try { execSync('claude mcp remove ai-lens -s project', { stdio: 'ignore', shell: true }); } catch {}
1110
- try { execSync('claude mcp remove ai-lens -s user', { stdio: 'ignore', shell: true }); } catch {}
1111
- cleanupEmptyMcpJson();
1112
- const escapedMcpUrl = process.platform === 'win32'
1113
- ? `"${mcpUrl.replace(/"/g, '""')}"`
1114
- : `'${mcpUrl.replace(/'/g, "'\\''")}'`;
1115
- execSync(
1116
- `claude mcp add --transport http ai-lens -s ${scope} ${escapedMcpUrl}`,
1117
- { stdio: 'inherit', shell: true },
1118
- );
1119
- success(` MCP server registered in Claude Code (${scope})`);
1120
- } catch (err) {
1121
- error(` Failed to register MCP server: ${err.message}`);
1122
- }
1123
- } else {
1124
- info(' Skipped');
1125
- }
1126
- }
1127
-
1128
- // Cursor MCP
1129
- const cursorDir = join(homedir(), '.cursor');
1130
- if (existsSync(cursorDir)) {
1131
- heading('MCP Server — Cursor');
1132
- let doSetup;
1133
- if (auto) {
1134
- doSetup = true;
1135
- } else {
1136
- const cursorMcpAnswer = await ask('Set up AI Lens MCP server in Cursor? [Y/n] ');
1137
- doSetup = !cursorMcpAnswer || cursorMcpAnswer.toLowerCase() === 'y';
1138
- }
1139
-
1140
- if (doSetup) {
1141
- try {
1142
- removeCursorMcp();
1143
- addCursorMcp(mcpUrl);
1144
- success(' MCP server registered in Cursor (~/.cursor/mcp.json)');
1145
- } catch (err) {
1146
- error(` Failed to register MCP server: ${err.message}`);
1147
- }
1148
- } else {
1149
- info(' Skipped');
1150
- }
1151
- }
1309
+ await setupMcpServers(serverUrl, {
1310
+ auto,
1311
+ mcpScope: flags.mcpScope,
1312
+ forcedScope: (flags.projectHooks && !flags.mcpScope) ? 'local' : null,
1313
+ projectRoot: flags.projectHooks ? resolve(process.cwd()) : null,
1314
+ });
1152
1315
  }
1153
1316
 
1154
1317
  // Quick verification
@@ -1278,7 +1441,8 @@ export default async function init() {
1278
1441
 
1279
1442
  /**
1280
1443
  * After a successful init, offer to import the developer's local Claude Code
1281
- * history. `--no-import` skips; `--import` or `--yes` runs it without prompting;
1444
+ * history. `--no-import` skips; `--import` runs it without prompting; `--yes`
1445
+ * does NOT import (opt-in only — avoids silently pulling personal history);
1282
1446
  * otherwise asks interactively. No-op if there's no `~/.claude/projects`.
1283
1447
  */
1284
1448
  async function maybeOfferImportHistory(flags) {
@@ -1301,18 +1465,21 @@ async function maybeOfferImportHistory(flags) {
1301
1465
  ? `Found ${preview.count} Claude Code session${preview.count === 1 ? '' : 's'} from ${day(preview.earliest)} to ${day(preview.latest)} (last 90d).`
1302
1466
  : 'Local Claude Code history found.';
1303
1467
 
1304
- let run = flags.importHistory || flags.yes;
1305
- if (run) {
1306
- info(` ${previewLine}`);
1307
- } else {
1468
+ info(` ${previewLine}`);
1469
+ // History import is opt-IN (importMode): `--import` runs, interactive asks,
1470
+ // `--yes` SKIPS. `--yes` used to auto-import (`|| flags.yes`), silently pulling
1471
+ // the full 90-day ~/.claude history (incl. personal projects) on a fresh config.
1472
+ const mode = importMode(flags);
1473
+ let run = mode === 'run';
1474
+ if (mode === 'prompt') {
1308
1475
  try {
1309
- const answer = (await ask(`${previewLine} Import now? (Y/n) `)).toLowerCase();
1476
+ const answer = (await ask(' Import now? (Y/n) ')).toLowerCase();
1310
1477
  run = answer === '' || answer === 'y' || answer === 'yes';
1311
1478
  } catch { run = false; }
1312
1479
  }
1313
1480
  if (!run) {
1314
1481
  blank();
1315
- info(' Skipped import. Run `npx -y ai-lens import claude-code` anytime to bring it in.');
1482
+ info(' Skipped local history import. Run `npx -y ai-lens import claude-code` (or pass `--import`) to bring it in.');
1316
1483
  return;
1317
1484
  }
1318
1485
 
package/cli/scan.js CHANGED
@@ -78,40 +78,68 @@ function hasAnyNonAiLensHooks(config) {
78
78
  return collectHookEntries(config).some(entry => !isAiLensHook(entry));
79
79
  }
80
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') {
81
+ function classifyHookProject({ tool, configs, installTarget, existingConfig, malformedInstallTarget = null }) {
82
+ if (configs.some(config => config === 'MALFORMED') || existingConfig === 'MALFORMED') {
89
83
  return {
84
+ tool,
90
85
  status: 'malformed',
91
- installTarget: null,
86
+ installTarget: malformedInstallTarget,
92
87
  existingConfig: null,
93
88
  };
94
89
  }
95
90
 
96
- if (hasAnyAiLensHook(settings) || hasAnyAiLensHook(settingsLocal)) {
91
+ if (configs.some(hasAnyAiLensHook) || hasAnyAiLensHook(existingConfig)) {
97
92
  return {
93
+ tool,
98
94
  status: 'installed',
99
95
  installTarget: null,
100
96
  existingConfig: null,
101
97
  };
102
98
  }
103
99
 
100
+ return {
101
+ tool,
102
+ status: configs.some(hasAnyNonAiLensHooks) || hasAnyNonAiLensHooks(existingConfig)
103
+ ? 'has non-ai-lens hooks'
104
+ : 'missing',
105
+ installTarget,
106
+ existingConfig,
107
+ };
108
+ }
109
+
110
+ function classifyClaudeProject(projectDir) {
111
+ const claudeDir = join(projectDir, '.claude');
112
+ const settingsPath = join(claudeDir, 'settings.json');
113
+ const settingsLocalPath = join(claudeDir, 'settings.local.json');
114
+ const settings = readJsonSafe(settingsPath);
115
+ const settingsLocal = readJsonSafe(settingsLocalPath);
116
+
104
117
  // AI Lens Claude hooks are per-machine / OS-specific (ADR 0003 — absolute node path,
105
118
  // conhost.exe on Windows). They must NEVER be written into a COMMITTED settings.json
106
119
  // (that re-introduces the cross-OS / cross-machine breakage MR !298 removed). Always
107
120
  // install into the gitignored, per-machine settings.local.json, which Claude Code
108
121
  // merges with settings.json at runtime — so a project's own non-ai-lens hooks in
109
122
  // settings.json keep working alongside.
110
- return {
111
- status: hasAnyNonAiLensHooks(settings) ? 'has non-ai-lens hooks' : 'missing',
123
+ return classifyHookProject({
124
+ tool: 'claude',
125
+ configs: [settings],
112
126
  installTarget: 'settings.local.json',
113
127
  existingConfig: settingsLocal,
114
- };
128
+ });
129
+ }
130
+
131
+ function classifyCodexProject(projectDir) {
132
+ const codexDir = join(projectDir, '.codex');
133
+ const hooksPath = join(codexDir, 'hooks.json');
134
+ const hooks = readJsonSafe(hooksPath);
135
+
136
+ return classifyHookProject({
137
+ tool: 'codex',
138
+ configs: [],
139
+ installTarget: 'hooks.json',
140
+ malformedInstallTarget: 'hooks.json',
141
+ existingConfig: hooks,
142
+ });
115
143
  }
116
144
 
117
145
  function scanRoot(rootPath, options, results, seen) {
@@ -133,18 +161,40 @@ function scanRoot(rootPath, options, results, seen) {
133
161
  const childPath = join(currentDir, child.name);
134
162
  if (isIgnoredByGitignore(rules, childPath)) continue;
135
163
 
136
- if (child.name === '.git' || child.name === '.claude') {
164
+ if (child.name === '.git' || child.name === '.claude' || child.name === '.codex') {
137
165
  const projectDir = resolve(currentDir);
138
166
  if (projectDir !== rootPath) {
139
167
  const depth = countDepth(rootPath, projectDir);
140
- if (depth <= maxDepth && !seen.has(projectDir)) {
141
- seen.add(projectDir);
142
- results.push({
143
- projectDir,
144
- relativePath: relative(rootPath, projectDir).replace(/\\/g, '/'),
145
- marker: child.name,
146
- ...classifyProject(projectDir),
147
- });
168
+ if (depth <= maxDepth) {
169
+ const projectKey = `${rootPath}:${projectDir}`;
170
+ if (!seen.has(projectKey)) {
171
+ seen.add(projectKey);
172
+ const base = {
173
+ projectDir,
174
+ relativePath: relative(rootPath, projectDir).replace(/\\/g, '/'),
175
+ marker: child.name,
176
+ };
177
+ // Classify by which markers ACTUALLY exist in the dir, not by which
178
+ // child readdir happened to yield first. readdirSync order is
179
+ // filesystem-dependent (unsorted), so gating Claude on
180
+ // `child.name !== '.codex'` dropped the Claude target whenever
181
+ // `.codex` was enumerated before `.git`/`.claude` in the same repo
182
+ // (latent on Linux; ANL-1126 review). Codex is offered for every
183
+ // project dir; Claude only for git/.claude repos (a .codex-only dir
184
+ // is not a Claude project).
185
+ const isClaudeProject = existsSync(join(projectDir, '.git'))
186
+ || existsSync(join(projectDir, '.claude'));
187
+ if (isClaudeProject) {
188
+ results.push({
189
+ ...base,
190
+ ...classifyClaudeProject(projectDir),
191
+ });
192
+ }
193
+ results.push({
194
+ ...base,
195
+ ...classifyCodexProject(projectDir),
196
+ });
197
+ }
148
198
  }
149
199
  }
150
200
  continue;
@@ -160,7 +210,7 @@ function scanRoot(rootPath, options, results, seen) {
160
210
  visit(rootPath);
161
211
  }
162
212
 
163
- export function scanNestedClaudeProjects(projectRoots, options = {}) {
213
+ export function scanNestedProjects(projectRoots, options = {}) {
164
214
  const maxDepth = Number.isInteger(options.maxDepth) ? options.maxDepth : DEFAULT_MAX_DEPTH;
165
215
  const roots = (projectRoots || []).map(path => resolve(path));
166
216
  const results = [];
@@ -171,7 +221,15 @@ export function scanNestedClaudeProjects(projectRoots, options = {}) {
171
221
  scanRoot(rootPath, { maxDepth }, results, seen);
172
222
  }
173
223
 
174
- return results.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
224
+ return results.sort((a, b) => (
225
+ a.relativePath.localeCompare(b.relativePath) || a.tool.localeCompare(b.tool)
226
+ ));
227
+ }
228
+
229
+ export function scanNestedClaudeProjects(projectRoots, options = {}) {
230
+ return scanNestedProjects(projectRoots, options)
231
+ .filter(result => result.tool === 'claude')
232
+ .map(({ tool, ...result }) => result);
175
233
  }
176
234
 
177
235
  export function summarizeNestedProjects(results) {
package/cli/status.js CHANGED
@@ -11,7 +11,7 @@ import { getVersionInfo, readLensConfig, detectInstalledTools, getCursorToolConf
11
11
  import { DATA_DIR, PENDING_DIR, SENDING_DIR, SESSION_PATHS_DIR, LOG_PATH, CAPTURE_LOG_PATH, LAST_STATUS_REPORT_PATH, getGitIdentity, getMonitoredProjects } from '../client/config.js';
12
12
  import { isLockStale } from '../client/sender.js';
13
13
  import { initLogger, info, success, warn, error, heading, blank } from './logger.js';
14
- import { scanNestedClaudeProjects, summarizeNestedProjects } from './scan.js';
14
+ import { scanNestedProjects, summarizeNestedProjects } from './scan.js';
15
15
 
16
16
  const INIT_LOG_PATH = join(DATA_DIR, 'init.log');
17
17
 
@@ -510,6 +510,16 @@ function checkGitIdentity() {
510
510
  return { ok: false, summary: 'not configured', detail: 'Git identity not configured (git config user.email)' };
511
511
  }
512
512
 
513
+ // Server-driven update nudge ([0093]): the sender caches the server's verdict to
514
+ // ~/.ai-lens/messages.json. Read-only here (no network); returns the outdated
515
+ // message if the server says this client is behind the latest published version.
516
+ function readOutdatedNudge() {
517
+ try {
518
+ const cache = JSON.parse(readFileSync(join(homedir(), '.ai-lens', 'messages.json'), 'utf-8'));
519
+ return (cache.messages || []).find(m => m && typeof m.id === 'string' && m.id.startsWith('outdated:')) || null;
520
+ } catch { return null; }
521
+ }
522
+
513
523
  export function checkClientFiles(tools = []) {
514
524
  // In repo-path mode the hooks run capture.js straight from the repo, so the
515
525
  // ~/.ai-lens/client/ copy (if any) is an UNUSED leftover from a prior copy-mode
@@ -529,6 +539,10 @@ export function checkClientFiles(tools = []) {
529
539
  if (copyExists) {
530
540
  detail += `\n Note: ~/.ai-lens/client/ exists but is UNUSED in repo-path mode (stale leftover from an old copy-install; safe to ignore).`;
531
541
  }
542
+ // Server nudge (if behind latest published): surface as an info line, but keep
543
+ // ok=true — a stale repo checkout is fixed by /sync, not a false ✗ here (Y4).
544
+ const repoNudge = readOutdatedNudge();
545
+ if (repoNudge) detail += `\n ! ${repoNudge.text.split('\n').join('\n ')}`;
532
546
  // Healthy as long as the hook-source version is readable. Don't compare against
533
547
  // the CLI: `ai-lens status` may run from a global npx CLI while the hooks point
534
548
  // at a checkout — that mismatch is expected, not an error. A stale repo checkout
@@ -557,9 +571,16 @@ export function checkClientFiles(tools = []) {
557
571
  const clientCommit = versionJson.commit || 'unknown';
558
572
  outdated = clientVersion !== cliVersion || clientCommit !== cliCommit;
559
573
  versionDetail = `\n Client version: ${clientVersion} (${clientCommit})`;
574
+ const nudge = readOutdatedNudge();
560
575
  if (outdated) {
561
576
  versionDetail += `\n CLI version: ${cliVersion} (${cliCommit})`;
562
- versionDetail += `\n ! Client is outdated — run: npx -y ai-lens init --yes`;
577
+ if (!nudge) versionDetail += `\n ! Client is outdated — run: npx -y ai-lens init --yes`;
578
+ }
579
+ // Server says behind latest published → copy-mode must re-init. Prefer the
580
+ // server's line (it carries the exact latest version + command).
581
+ if (nudge) {
582
+ versionDetail += `\n ! ${nudge.text.split('\n').join('\n ')}`;
583
+ outdated = true;
563
584
  }
564
585
  } catch {
565
586
  versionDetail = `\n Client version: unknown (version.json not found — run: npx -y ai-lens init)`;
@@ -1564,7 +1585,7 @@ export default async function status({ report = false } = {}) {
1564
1585
 
1565
1586
  if (hasProjectHooks) {
1566
1587
  const nestedRoots = getMonitoredProjects() || [process.cwd()];
1567
- const nestedProjects = scanNestedClaudeProjects(nestedRoots);
1588
+ const nestedProjects = scanNestedProjects(nestedRoots);
1568
1589
  const nestedSummary = summarizeNestedProjects(nestedProjects);
1569
1590
  const nestedUnhooked = nestedProjects.filter(result => result.status !== 'installed');
1570
1591
  printLine('Nested projects', {
@@ -1572,10 +1593,10 @@ export default async function status({ report = false } = {}) {
1572
1593
  summary: nestedSummary.total === 0
1573
1594
  ? 'none found'
1574
1595
  : nestedSummary.unhooked === 0
1575
- ? `${nestedSummary.total} nested project(s), all hooked`
1576
- : `${nestedSummary.unhooked} unhooked / ${nestedSummary.total} nested project(s)`,
1596
+ ? `${nestedSummary.total} nested hook target(s), all hooked`
1597
+ : `${nestedSummary.unhooked} unhooked / ${nestedSummary.total} nested hook target(s)`,
1577
1598
  detail: nestedUnhooked.length > 0
1578
- ? `nested_unhooked_projects:\n${nestedUnhooked.map(result => `- ${result.projectDir} (${result.status})`).join('\n')}`
1599
+ ? `nested_unhooked_projects:\n${nestedUnhooked.map(result => `- ${result.projectDir} (${result.tool}: ${result.status})`).join('\n')}`
1579
1600
  : 'nested_unhooked_projects: none',
1580
1601
  });
1581
1602
  }
package/client/capture.js CHANGED
@@ -29,6 +29,8 @@ import {
29
29
  getGitIdentity,
30
30
  getGitMetadata,
31
31
  getMonitoredProjects,
32
+ MESSAGES_PATH,
33
+ MESSAGES_SHOWN_PATH,
32
34
  } from './config.js';
33
35
  import { isLockStale, isSenderBackoffActive } from './sender.js';
34
36
  import { toNumberOrNull, buildTokenUsageRaw } from './token-usage.js';
@@ -1379,6 +1381,86 @@ export function trySpawnSender() {
1379
1381
  }
1380
1382
 
1381
1383
 
1384
+ // =============================================================================
1385
+ // Server-driven client messages ([0093]) — render on SessionStart
1386
+ // =============================================================================
1387
+
1388
+ const MESSAGE_THROTTLE_MS = 24 * 60 * 60 * 1000; // show a given message ≤ once/day
1389
+ const MESSAGE_MAX_LEN = 2000;
1390
+ // Appended only for Cursor (additional_context → agent context) to raise the odds
1391
+ // the agent relays the nudge to the human. NOT added for Claude (direct banner).
1392
+ const CURSOR_RELAY_SUFFIX = '\n(Сообщи это пользователю в начале своего ответа.)';
1393
+
1394
+ function readJsonFileSafe(path) {
1395
+ try { return JSON.parse(readFileSync(path, 'utf-8')); } catch { return null; }
1396
+ }
1397
+
1398
+ function sanitizeMessageText(s) {
1399
+ if (typeof s !== 'string') return '';
1400
+ // Strip control chars except newline/tab; cap length (defense-in-depth — the
1401
+ // text is server-controlled and enters the tool's context).
1402
+ let out = '';
1403
+ for (const ch of s) {
1404
+ const c = ch.codePointAt(0);
1405
+ if (c === 9 || c === 10 || (c >= 32 && c !== 127)) out += ch;
1406
+ if (out.length >= MESSAGE_MAX_LEN) break;
1407
+ }
1408
+ return out.slice(0, MESSAGE_MAX_LEN);
1409
+ }
1410
+
1411
+ /** First cache message whose id hasn't been shown within the throttle window. */
1412
+ function pickUnthrottledMessage(messages, shownMap, nowMs) {
1413
+ for (const m of messages) {
1414
+ if (!m || !m.id || typeof m.text !== 'string') continue;
1415
+ const last = shownMap[m.id] ? Date.parse(shownMap[m.id]) : 0;
1416
+ if (!Number.isFinite(last) || nowMs - last >= MESSAGE_THROTTLE_MS) return m;
1417
+ }
1418
+ return null;
1419
+ }
1420
+
1421
+ /** Atomically record `id` as shown now, pruning ids no longer in the cache. */
1422
+ function markShown(id, validIds, nowIso) {
1423
+ try {
1424
+ const map = readJsonFileSafe(MESSAGES_SHOWN_PATH) || {};
1425
+ const next = {};
1426
+ for (const k of validIds) if (map[k]) next[k] = map[k];
1427
+ next[id] = nowIso;
1428
+ const tmp = MESSAGES_SHOWN_PATH + '.tmp.' + process.pid;
1429
+ writeFileSync(tmp, JSON.stringify(next));
1430
+ renameSync(tmp, MESSAGES_SHOWN_PATH);
1431
+ } catch { /* best-effort */ }
1432
+ }
1433
+
1434
+ /**
1435
+ * On SessionStart, render a cached server message into the tool's context via the
1436
+ * one stdout write capture.js ever makes. Claude → `systemMessage` (banner);
1437
+ * Cursor → `additional_context` (+ relay suffix). Codex/unknown → nothing.
1438
+ * Identity-independent (reads only the local cache), throttled per id, and fully
1439
+ * isolated: any failure is swallowed so it can never flip capture to exit≠0.
1440
+ */
1441
+ export function maybeEmitSessionStartMessage(primary, { now = Date.now() } = {}) {
1442
+ try {
1443
+ if (!primary || primary.type !== 'SessionStart') return;
1444
+ const source = primary.source;
1445
+ if (source !== 'claude_code' && source !== 'cursor') return; // codex/unknown deferred
1446
+ const cache = readJsonFileSafe(MESSAGES_PATH);
1447
+ const messages = cache && Array.isArray(cache.messages) ? cache.messages : null;
1448
+ if (!messages || messages.length === 0) return;
1449
+ const shownMap = readJsonFileSafe(MESSAGES_SHOWN_PATH) || {};
1450
+ const msg = pickUnthrottledMessage(messages, shownMap, now);
1451
+ if (!msg) return;
1452
+
1453
+ const text = sanitizeMessageText(msg.text);
1454
+ if (!text) return;
1455
+ const out = source === 'claude_code'
1456
+ ? { systemMessage: text }
1457
+ : { additional_context: text + CURSOR_RELAY_SUFFIX };
1458
+
1459
+ process.stdout.write(JSON.stringify(out));
1460
+ markShown(msg.id, messages.map(m => m && m.id).filter(Boolean), new Date(now).toISOString());
1461
+ } catch { /* never break capture */ }
1462
+ }
1463
+
1382
1464
  // =============================================================================
1383
1465
  // Main
1384
1466
  // =============================================================================
@@ -1435,6 +1517,12 @@ async function main() {
1435
1517
  // hook invocation on the same machine.
1436
1518
  const primary = events[0];
1437
1519
 
1520
+ // Render a server-driven update nudge ([0093]) on SessionStart — BEFORE the
1521
+ // project_filter / no_email / dedup gates below (those drop exactly the
1522
+ // stale/unknown devs we want to reach). Identity-independent, reads only the
1523
+ // local cache, never throws → can't flip this process to a non-zero exit.
1524
+ maybeEmitSessionStartMessage(primary);
1525
+
1438
1526
  // Filter by monitored projects (if configured) — based on the primary event.
1439
1527
  // If the primary is filtered out, drop the entire batch (the per-call events
1440
1528
  // share the same project_path).
package/client/config.js CHANGED
@@ -24,6 +24,10 @@ export const LOG_PATH = join(DATA_DIR, 'sender.log');
24
24
  export const CAPTURE_LOG_PATH = join(DATA_DIR, 'capture.log');
25
25
  export const SENDER_BACKOFF_PATH = join(DATA_DIR, 'sender-backoff.json');
26
26
  export const LAST_STATUS_REPORT_PATH = join(DATA_DIR, 'last-status-report');
27
+ // Server-driven client messages ([0093]): sender caches them here; capture.js
28
+ // renders on SessionStart. `messages-shown` is the per-id throttle ledger.
29
+ export const MESSAGES_PATH = join(DATA_DIR, 'messages.json');
30
+ export const MESSAGES_SHOWN_PATH = join(DATA_DIR, 'messages-shown.json');
27
31
  export const LOG_MAX_AGE_DAYS = 30;
28
32
  const GIT_ROOT_CACHE = new Map();
29
33
  // Pipe stderr (instead of inheriting it) so that "fatal: not a git repository"
@@ -154,6 +158,24 @@ export function getClientVersion() {
154
158
  return { version: 'unknown', commit: 'unknown' };
155
159
  }
156
160
 
161
+ /**
162
+ * Install mode of the RUNNING client — mirrors getClientVersion()'s three-way
163
+ * resolution so mode and version never disagree:
164
+ * 'copy' — sibling version.json (copied into ~/.ai-lens/client/ by init)
165
+ * 'repo' — running from the ai-lens package checkout (--use-repo-path / npx)
166
+ * 'unknown' — neither resolves
167
+ * Sent as X-Client-Mode so the server can return a mode-aware update nudge ([0093]).
168
+ */
169
+ export function getClientMode() {
170
+ const here = dirname(fileURLToPath(import.meta.url));
171
+ try { readFileSync(join(here, 'version.json'), 'utf-8'); return 'copy'; } catch { /* not a copy */ }
172
+ try {
173
+ const pkg = JSON.parse(readFileSync(resolve(here, '..', 'package.json'), 'utf-8'));
174
+ if (pkg?.name === 'ai-lens' && pkg.version) return 'repo';
175
+ } catch { /* not a checkout */ }
176
+ return 'unknown';
177
+ }
178
+
157
179
  export function getGitIdentity(cwd) {
158
180
  let email = null;
159
181
  let name = null;
package/client/sender.js CHANGED
@@ -41,6 +41,8 @@ import {
41
41
  getServerUrl,
42
42
  getAuthToken,
43
43
  getClientVersion,
44
+ getClientMode,
45
+ MESSAGES_PATH,
44
46
  DEFAULT_SERVER_URL,
45
47
  log,
46
48
  } from './config.js';
@@ -627,12 +629,32 @@ export function isTransientFetchError(err) {
627
629
  return false;
628
630
  }
629
631
 
632
+ /**
633
+ * Cache server-driven client messages ([0093]) for capture.js to render on
634
+ * SessionStart. `undefined` = no response parsed this run → leave cache as-is;
635
+ * `[]` = client is current → remove the cache (so a fixed client stops nudging);
636
+ * non-empty → atomic tmp+rename write. Best-effort: never break the flush.
637
+ */
638
+ function cacheClientMessages(messages) {
639
+ if (!Array.isArray(messages)) return;
640
+ try {
641
+ if (messages.length === 0) {
642
+ try { unlinkSync(MESSAGES_PATH); } catch { /* already absent */ }
643
+ return;
644
+ }
645
+ const tmp = MESSAGES_PATH + '.tmp.' + process.pid;
646
+ writeFileSync(tmp, JSON.stringify({ updatedAt: new Date().toISOString(), messages }));
647
+ renameSync(tmp, MESSAGES_PATH);
648
+ } catch { /* best-effort */ }
649
+ }
650
+
630
651
  function buildHeaders(identity, authToken) {
631
652
  const { version: clientVersion, commit: clientCommit } = getClientVersion();
632
653
  const headers = {
633
654
  'Content-Type': 'application/json',
634
655
  'Connection': 'close',
635
656
  'X-Client-Version': `${clientVersion}+${clientCommit}`,
657
+ 'X-Client-Mode': getClientMode(),
636
658
  };
637
659
  if (identity.email) headers['X-Developer-Git-Email'] = identity.email;
638
660
  if (identity.name) headers['X-Developer-Name'] = encodeURIComponent(identity.name);
@@ -849,6 +871,7 @@ async function main() {
849
871
  }
850
872
 
851
873
  const sentEventIds = new Set();
874
+ let lastMessages; // server-driven client messages ([0093]) from the last ok POST
852
875
 
853
876
  try {
854
877
  for (const { identity, events: batch } of byDeveloper.values()) {
@@ -857,6 +880,7 @@ async function main() {
857
880
  for (const chunk of chunks) {
858
881
  const result = await postEvents(serverUrl, chunk, identity, hasAuthToken ? authToken : null, { lockPath });
859
882
  refreshLock(lockPath);
883
+ if (Array.isArray(result?.messages)) lastMessages = result.messages;
860
884
  totalReceived += (result?.received ?? 0);
861
885
  if ((result?.skipped ?? 0) > 0) {
862
886
  log({ msg: 'server-skipped', skipped: result.skipped, chunk_size: chunk.length, developer: identity.email });
@@ -869,6 +893,7 @@ async function main() {
869
893
  log({ msg: 'sent', events: totalReceived, chunks: chunks.length, developer: identity.email, projects, server: serverUrl });
870
894
  }
871
895
  clearSenderBackoff();
896
+ cacheClientMessages(lastMessages);
872
897
  commitQueue(sendingDir, acquiredFiles);
873
898
  trySpawnStatusReport();
874
899
  } catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-lens",
3
- "version": "0.8.107",
3
+ "version": "0.8.109",
4
4
  "type": "module",
5
5
  "description": "Centralized session analytics for AI coding tools",
6
6
  "bin": {