ai-lens 0.8.107 → 0.8.108

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
+ 3b3e261
package/CHANGELOG.md CHANGED
@@ -2,6 +2,10 @@
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.108 — 2026-06-22
6
+ - 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.
7
+ - 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.
8
+
5
9
  ## 0.8.107 — 2026-06-19
6
10
  - 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
11
  - 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 });
@@ -193,6 +193,14 @@ function makeNestedClaudeTool(projectDir, capturePathInHooks, ctx = null) {
193
193
  return tool;
194
194
  }
195
195
 
196
+ function makeNestedCodexTool(projectDir, capturePathInHooks, ctx = null) {
197
+ const tool = getCodexToolConfig(projectDir, `Codex (${projectDir})`, ctx);
198
+ if (capturePathInHooks) {
199
+ tool.hookDefs = getCodexHookDefsWithPath(capturePathInHooks, ctx);
200
+ }
201
+ return tool;
202
+ }
203
+
196
204
  async function deviceCodeAuth(serverUrl) {
197
205
  // 1. Fetch Auth0 config from server
198
206
  let config;
@@ -353,6 +361,9 @@ function getInitArgs() {
353
361
  case '--no-mcp':
354
362
  flags.noMcp = true;
355
363
  break;
364
+ case '--mcp-only':
365
+ flags.mcpOnly = true;
366
+ break;
356
367
  case '--no-hooks':
357
368
  flags.noHooks = true;
358
369
  break;
@@ -379,7 +390,7 @@ function getInitArgs() {
379
390
  const a = args[i];
380
391
  if (a.startsWith('-')) {
381
392
  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)) {
393
+ } else if (['server', 'projects', 'yes', 'no-mcp', 'mcp-only', 'no-hooks', 'project-hooks', 'use-repo-path', 'install-launcher', 'mcp-scope'].includes(a)) {
383
394
  process.stderr.write(`Warning: unexpected argument "${a}" — did you mean --${a}?\n`);
384
395
  }
385
396
  }
@@ -389,6 +400,138 @@ function getInitArgs() {
389
400
  return flags;
390
401
  }
391
402
 
403
+ /**
404
+ * Resolve the server URL from --server flag / saved config / interactive prompt.
405
+ * Normalizes scheme + trailing slash and validates. Shared by the full init flow
406
+ * and the --mcp-only path.
407
+ */
408
+ async function resolveServerUrl(flags, auto, currentConfig) {
409
+ const currentServer = currentConfig.serverUrl || 'http://localhost:3000';
410
+ let serverUrl;
411
+ if (flags.server) {
412
+ serverUrl = flags.server.replace(/\/+$/, '');
413
+ } else if (auto) {
414
+ serverUrl = currentServer;
415
+ } else {
416
+ const serverInput = await ask(`Server URL (Enter = ${currentServer}): `);
417
+ serverUrl = (serverInput || currentServer).replace(/\/+$/, '');
418
+ }
419
+ if (!/^https?:\/\//i.test(serverUrl)) serverUrl = `http://${serverUrl}`;
420
+ try { new URL(serverUrl); } catch { error(`Invalid server URL: ${serverUrl}`); process.exit(1); }
421
+ return serverUrl;
422
+ }
423
+
424
+ /**
425
+ * Register the AI Lens MCP server in Claude Code and/or Cursor (HTTP transport —
426
+ * auth via OAuth in browser, no token needed). Shared by the full init flow and
427
+ * the --mcp-only path.
428
+ *
429
+ * @param {string} serverUrl
430
+ * @param {object} opts
431
+ * @param {boolean} opts.auto - non-interactive (--yes)
432
+ * @param {string} [opts.mcpScope] - explicit --mcp-scope (user|local|project)
433
+ * @param {string} [opts.forcedScope] - pre-resolved Claude scope; skips the prompt
434
+ * @param {string} [opts.projectRoot] - project dir to use when the scope is local/project
435
+ */
436
+ async function setupMcpServers(serverUrl, { auto, mcpScope, forcedScope = null, projectRoot = null }) {
437
+ const mcpUrl = `${serverUrl}/mcp`;
438
+
439
+ const claudeDir = join(homedir(), '.claude');
440
+ const hasClaudeDir = existsSync(claudeDir);
441
+ let hasClaudeCli = false;
442
+ try { execSync(process.platform === 'win32' ? 'where claude' : 'which claude', { stdio: 'ignore' }); hasClaudeCli = true; } catch {}
443
+
444
+ const cursorDir = join(homedir(), '.cursor');
445
+ const hasCursorDir = existsSync(cursorDir);
446
+
447
+ // A local/project scope targets the project .cursor/mcp.json; a user scope (or no
448
+ // project context) targets the global ~/.cursor/mcp.json. Owned here so the full
449
+ // init flow and --mcp-only map scope → Cursor target the same way.
450
+ const cursorRootFor = (scope) => ((scope === 'local' || scope === 'project') ? projectRoot : null);
451
+
452
+ // Resolve the Claude scope when it's known without prompting. null ⇒ ask
453
+ // interactively in the Claude block below, and the Cursor target then follows it.
454
+ let resolvedScope = forcedScope
455
+ || (mcpScope && ['local', 'project', 'user'].includes(mcpScope) ? mcpScope : null)
456
+ || (auto ? 'user' : null);
457
+
458
+ if (!hasClaudeDir && !hasCursorDir && !cursorRootFor(resolvedScope)) {
459
+ warn(' No MCP-capable tool found (~/.claude or ~/.cursor) — nothing to register.');
460
+ return;
461
+ }
462
+ if (hasClaudeDir && !hasClaudeCli) {
463
+ warn(' Claude Code detected but the `claude` CLI is not on PATH — skipping Claude MCP registration.');
464
+ }
465
+
466
+ // Claude Code MCP
467
+ if (hasClaudeDir && hasClaudeCli) {
468
+ heading('MCP Server — Claude Code');
469
+ let doSetup;
470
+ if (auto) {
471
+ doSetup = true;
472
+ } else {
473
+ const mcpAnswer = await ask('Set up AI Lens MCP server in Claude Code? [Y/n] ');
474
+ doSetup = !mcpAnswer || mcpAnswer.toLowerCase() === 'y';
475
+ }
476
+
477
+ if (doSetup) {
478
+ if (!resolvedScope) {
479
+ const scopeInput = await ask(' Scope — user, local, or project? (Enter = user): ');
480
+ resolvedScope = ['local', 'project', 'user'].includes(scopeInput) ? scopeInput : 'user';
481
+ }
482
+ const scope = resolvedScope;
483
+ try {
484
+ try { execSync('claude mcp remove ai-lens -s local', { stdio: 'ignore', shell: true }); } catch {}
485
+ try { execSync('claude mcp remove ai-lens -s project', { stdio: 'ignore', shell: true }); } catch {}
486
+ try { execSync('claude mcp remove ai-lens -s user', { stdio: 'ignore', shell: true }); } catch {}
487
+ cleanupEmptyMcpJson();
488
+ const escapedMcpUrl = process.platform === 'win32'
489
+ ? `"${mcpUrl.replace(/"/g, '""')}"`
490
+ : `'${mcpUrl.replace(/'/g, "'\\''")}'`;
491
+ execSync(
492
+ `claude mcp add --transport http ai-lens -s ${scope} ${escapedMcpUrl}`,
493
+ { stdio: 'inherit', shell: true },
494
+ );
495
+ success(` MCP server registered in Claude Code (${scope})`);
496
+ } catch (err) {
497
+ error(` Failed to register MCP server: ${err.message}`);
498
+ }
499
+ } else {
500
+ info(' Skipped');
501
+ }
502
+ }
503
+
504
+ // Cursor MCP. Mirror the resolved scope: project file for local/project, else global.
505
+ // Gate on a project target too, so a project-scoped install still registers even when
506
+ // the global ~/.cursor doesn't exist (fresh machine / CI / Cursor-only-in-project).
507
+ const cursorProjectRoot = cursorRootFor(resolvedScope);
508
+ if (hasCursorDir || cursorProjectRoot) {
509
+ heading('MCP Server — Cursor');
510
+ let doSetup;
511
+ if (auto) {
512
+ doSetup = true;
513
+ } else {
514
+ const cursorMcpAnswer = await ask('Set up AI Lens MCP server in Cursor? [Y/n] ');
515
+ doSetup = !cursorMcpAnswer || cursorMcpAnswer.toLowerCase() === 'y';
516
+ }
517
+
518
+ if (doSetup) {
519
+ try {
520
+ removeCursorMcp();
521
+ addCursorMcp(mcpUrl, { projectRoot: cursorProjectRoot });
522
+ const where = cursorProjectRoot
523
+ ? join(cursorProjectRoot, '.cursor', 'mcp.json')
524
+ : '~/.cursor/mcp.json';
525
+ success(` MCP server registered in Cursor (${where})`);
526
+ } catch (err) {
527
+ error(` Failed to register MCP server: ${err.message}`);
528
+ }
529
+ } else {
530
+ info(' Skipped');
531
+ }
532
+ }
533
+ }
534
+
392
535
  export default async function init() {
393
536
  const flags = getInitArgs();
394
537
  const auto = flags.yes || false;
@@ -400,6 +543,50 @@ export default async function init() {
400
543
  heading(`AI Lens — Init v${version} (${commit})`);
401
544
  detail(`capture.js: ${CAPTURE_PATH}`);
402
545
 
546
+ // --mcp-only: register ONLY the MCP server, mirroring the existing AI Lens
547
+ // install scope (local/project vs global/user). Skips hooks, auth, and import.
548
+ if (flags.mcpOnly) {
549
+ if (flags.noMcp) {
550
+ error('--mcp-only cannot be combined with --no-mcp.');
551
+ process.exit(1);
552
+ }
553
+ heading('Configuration');
554
+ const currentConfig = readLensConfig();
555
+ const serverUrl = await resolveServerUrl(flags, auto, currentConfig);
556
+ info(` Server: ${serverUrl}`);
557
+
558
+ // Soft health check — don't block registration (the server may be briefly down),
559
+ // but warn so a typo'd URL doesn't silently register a dead MCP (verification is skipped).
560
+ try {
561
+ const health = await getJson(`${serverUrl}/api/health`);
562
+ if (health.status !== 'ok') warn(` Server responded with unexpected status: ${JSON.stringify(health)}`);
563
+ } catch (err) {
564
+ warn(` Server unreachable (${err.message}) — registering MCP anyway; double-check the URL.`);
565
+ }
566
+
567
+ // Persist the server URL so it sticks for later runs.
568
+ saveLensConfig({ ...currentConfig, serverUrl });
569
+
570
+ // Scope precedence: explicit --mcp-scope > --project-hooks > detected footprint > user.
571
+ const cwd = resolve(process.cwd());
572
+ let forcedScope;
573
+ if (flags.mcpScope && ['user', 'local', 'project'].includes(flags.mcpScope)) {
574
+ forcedScope = flags.mcpScope;
575
+ } else if (flags.projectHooks) {
576
+ forcedScope = 'local';
577
+ } else {
578
+ const detected = detectAiLensInstallScope(cwd); // 'project' | 'user' | null
579
+ forcedScope = detected === 'project' ? 'local' : 'user';
580
+ }
581
+ info(` Install scope: ${forcedScope === 'user' ? 'global (user)' : 'local (project)'}`);
582
+
583
+ await setupMcpServers(serverUrl, { auto, forcedScope, projectRoot: cwd });
584
+
585
+ heading('Done');
586
+ info(' MCP-only setup complete — hooks, auth, and import were skipped.');
587
+ return;
588
+ }
589
+
403
590
  // Resolve a stable node binary path up-front. The same resolution is baked into
404
591
  // the per-machine launcher (run.sh / run.cmd) and into hook commands. We only
405
592
  // need it when hooks will actually be written; --no-hooks skips it so users
@@ -546,20 +733,7 @@ export default async function init() {
546
733
  const currentConfig = readLensConfig();
547
734
 
548
735
  // 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); }
736
+ const serverUrl = await resolveServerUrl(flags, auto, currentConfig);
563
737
  info(` Server: ${serverUrl}`);
564
738
 
565
739
  // Project filter
@@ -756,34 +930,50 @@ export default async function init() {
756
930
 
757
931
  let nestedScan = [];
758
932
  let nestedPending = [];
933
+ let nestedCodexTrust = [];
759
934
  if (flags.projectHooks) {
760
935
  const trackedRoots = getTrackedRoots(newConfig.projects, resolve(process.cwd()));
761
- nestedScan = scanNestedClaudeProjects(trackedRoots);
936
+ nestedScan = scanNestedProjects(trackedRoots);
762
937
 
763
938
  if (nestedScan.length > 0) {
764
- heading('Nested Claude Code projects');
939
+ heading('Nested projects');
765
940
  for (const result of nestedScan) {
766
941
  const installNote = result.installTarget ? ` -> install in ${result.installTarget}` : '';
767
- info(` ${result.relativePath}: ${result.status}${installNote}`);
942
+ info(` ${result.relativePath} (${result.tool}): ${result.status}${installNote}`);
768
943
  }
769
944
  const summary = summarizeNestedProjects(nestedScan);
770
- detail(`Found ${summary.total} nested project(s), ${summary.unhooked} need attention.`);
945
+ detail(`Found ${summary.total} nested hook target(s), ${summary.unhooked} need attention.`);
771
946
  blank();
772
947
  }
773
948
 
774
949
  const nestedActionable = nestedScan.filter(result => result.installTarget);
950
+ nestedCodexTrust = nestedScan
951
+ .filter(result => result.tool === 'codex' && !result.installTarget)
952
+ .map(result => ({ tool: makeNestedCodexTool(result.projectDir, null, ctx) }));
775
953
  let shouldInstallNested = auto && nestedActionable.length > 0;
776
954
  if (!auto && nestedActionable.length > 0) {
777
- const answer = await ask(`Install hooks in ${nestedActionable.length} nested Claude Code project(s)? [Y/n] `);
955
+ const answer = await ask(`Install hooks in ${nestedActionable.length} nested hook target(s)? [Y/n] `);
778
956
  shouldInstallNested = !answer || ['y', 'yes'].includes(answer.toLowerCase());
779
957
  }
780
958
 
781
959
  if (shouldInstallNested) {
782
960
  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);
961
+ let tool;
962
+ if (result.tool === 'codex') {
963
+ const capturePathInHooks = flags.useRepoPath
964
+ ? (() => {
965
+ const abs = resolve(REPO_CAPTURE_PATH);
966
+ const userHome = homedir();
967
+ return abs.startsWith(userHome) ? `~${abs.slice(userHome.length).replace(/\\/g, '/')}` : abs.replace(/\\/g, '/');
968
+ })()
969
+ : null;
970
+ tool = makeNestedCodexTool(result.projectDir, capturePathInHooks, ctx);
971
+ } else {
972
+ const capturePathInHooks = flags.useRepoPath
973
+ ? relative(result.projectDir, resolve(REPO_CAPTURE_PATH)).replace(/\\/g, '/')
974
+ : null;
975
+ tool = makeNestedClaudeTool(result.projectDir, capturePathInHooks, ctx);
976
+ }
787
977
  tool.configPath = join(tool.dirPath, result.installTarget);
788
978
  return {
789
979
  tool,
@@ -822,7 +1012,7 @@ export default async function init() {
822
1012
  // Idempotent: re-run hook trust enrollment even when no hooks needed
823
1013
  // rewriting. Without this, a previously-installed-but-untrusted Codex
824
1014
  // hook stays inert until the next time hooks themselves change.
825
- for (const { tool } of analyses) {
1015
+ for (const { tool } of [...analyses, ...nestedCodexTrust]) {
826
1016
  if (tool.name !== 'Codex' && !tool.name.startsWith('Codex')) continue;
827
1017
  try {
828
1018
  const r = enableCodexHookTrust(tool.configPath);
@@ -861,8 +1051,10 @@ export default async function init() {
861
1051
 
862
1052
  for (const { tool, analysis } of [...pending, ...nestedPending]) {
863
1053
  try {
864
- // Backup malformed shared configs (e.g. ~/.claude/settings.json) before overwriting
865
- if (analysis.status === 'malformed' && tool.sharedConfig) {
1054
+ // Backup malformed configs before overwriting. analyzeToolHooks already
1055
+ // renames non-shared malformed files; nested scan is read-only, so it
1056
+ // reaches this path and needs the backup here.
1057
+ if (analysis.status === 'malformed' && existsSync(tool.configPath)) {
866
1058
  try { copyFileSync(tool.configPath, tool.configPath + '.bak'); } catch { /* file may be gone */ }
867
1059
  }
868
1060
  const existingConfig = analysis.config || null;
@@ -882,7 +1074,7 @@ export default async function init() {
882
1074
  // key, no `file:` prefix) — the same state the TUI "Trust all" flow writes,
883
1075
  // but driven by AI Lens at install time. With it, `codex exec` fires hooks
884
1076
  // with no interactive step.
885
- for (const { tool } of [...pending, ...nestedPending]) {
1077
+ for (const { tool } of [...pending, ...nestedPending, ...nestedCodexTrust]) {
886
1078
  if (tool.name !== 'Codex' && !tool.name.startsWith('Codex')) continue;
887
1079
  try {
888
1080
  const r = enableCodexHookTrust(tool.configPath);
@@ -1074,81 +1266,17 @@ export default async function init() {
1074
1266
  } catch {}
1075
1267
  }
1076
1268
 
1077
- // MCP setup (HTTP transport — auth via OAuth in browser, no token needed)
1269
+ // MCP setup (HTTP transport — auth via OAuth in browser, no token needed).
1270
+ // --project-hooks installs hooks at project scope, so mirror that for the MCP:
1271
+ // default the scope to local and target the project .cursor/mcp.json (an explicit
1272
+ // --mcp-scope still wins; setupMcpServers maps scope → Cursor target).
1078
1273
  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
- }
1274
+ await setupMcpServers(serverUrl, {
1275
+ auto,
1276
+ mcpScope: flags.mcpScope,
1277
+ forcedScope: (flags.projectHooks && !flags.mcpScope) ? 'local' : null,
1278
+ projectRoot: flags.projectHooks ? resolve(process.cwd()) : null,
1279
+ });
1152
1280
  }
1153
1281
 
1154
1282
  // Quick verification
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.108",
4
4
  "type": "module",
5
5
  "description": "Centralized session analytics for AI coding tools",
6
6
  "bin": {