ai-lens 0.8.105 → 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
- 0a107d0
1
+ 3b3e261
package/CHANGELOG.md CHANGED
@@ -2,6 +2,17 @@
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
+
9
+ ## 0.8.107 — 2026-06-19
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.
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).
12
+
13
+ ## 0.8.106 — 2026-06-19
14
+ - fix: no more console-window flash on every hook event on Windows (Cursor and Claude Code). The flash was the background sender that `capture.js` launches after each event — a detached console spawn pops a window even when hidden. It's now started through a tiny GUI-subsystem launcher (`run-hidden.vbs`), which has no console at all, so capture stays invisible and still ships reliably. If a corporate policy/AV blocks the Windows Script Host, it automatically falls back to the previous behaviour so events never stop shipping. macOS/Linux unchanged.
15
+
5
16
  ## 0.8.105 — 2026-06-18
6
17
  - fix: Cyrillic (and other non-ASCII) in Claude Code hook payloads is no longer mangled on Windows. The windowless hook launcher read stdin through PowerShell's console codepage (OEM, e.g. CP866 on Russian Windows), corrupting prompts/paths on the way in; it now reads the raw stdin bytes and decodes UTF-8 directly. The earlier 0.8.102 fix only covered the write side, so machines that adopted the windowless launcher saw the corruption reappear.
7
18
 
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
@@ -420,7 +420,11 @@ export function listClientFiles(sourceDir = join(__dirname, '..', 'client')) {
420
420
  // .js — all client modules (sibling imports must all be present).
421
421
  // .ps1 — the Windows windowless hook launcher (ai-lens-hook.ps1), used by Cursor AND
422
422
  // Claude Code; a missing launcher would break every Windows hook, so ship it too.
423
- return readdirSync(sourceDir).filter(f => f.endsWith('.js') || f.endsWith('.ps1')).sort();
423
+ // .vbs run-hidden.vbs, the Windows hidden+detached sender launcher capture.js uses
424
+ // to start the sender without a console flash; missing it would reintroduce the flash.
425
+ return readdirSync(sourceDir)
426
+ .filter(f => f.endsWith('.js') || f.endsWith('.ps1') || f.endsWith('.vbs'))
427
+ .sort();
424
428
  }
425
429
 
426
430
  /**
@@ -1186,6 +1190,50 @@ export function detectInstalledTools(ctx = null) {
1186
1190
  return tools.filter(t => existsSync(t.dirPath));
1187
1191
  }
1188
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
+
1189
1237
  // ---------------------------------------------------------------------------
1190
1238
  // Analysis
1191
1239
  // ---------------------------------------------------------------------------
@@ -1211,8 +1259,10 @@ export function analyzeToolHooks(tool, opts = {}) {
1211
1259
  try {
1212
1260
  config = JSON.parse(raw);
1213
1261
  } catch (err) {
1214
- // For shared config files (settings.json), don't backup/rename — other tools depend on it
1215
- 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) {
1216
1266
  return { status: 'malformed', error: err.message, disableAllHooks: false };
1217
1267
  }
1218
1268
  const bakPath = tool.configPath + '.bak';
@@ -1803,13 +1853,20 @@ function readJsonSafe(path) {
1803
1853
 
1804
1854
  /**
1805
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.
1806
1860
  */
1807
- export function addCursorMcp(mcpUrl) {
1808
- 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: {} };
1809
1866
  if (!config.mcpServers) config.mcpServers = {};
1810
1867
  config.mcpServers['ai-lens'] = cursorMcpEntry(mcpUrl);
1811
- mkdirSync(dirname(CURSOR_MCP_GLOBAL), { recursive: true });
1812
- 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');
1813
1870
  }
1814
1871
 
1815
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
  }
@@ -11,18 +11,14 @@
11
11
  # no flash. It is fail-open: on ANY error it exits 0 and writes NOTHING to stdout (Cursor
12
12
  # parses a hook's stdout as JSON — leaking an error there would break the hook).
13
13
  #
14
- # Payload source differs by caller, so read BOTH:
15
- # - Cursor pipes it through the PowerShell pipeline $input.
16
- # - Claude Code pipes it to the process's OS stdin read it as raw bytes.
17
- # Read $input first; if empty (Claude Code invocation), read the OS stdin stream.
18
- #
19
- # Why raw bytes (not [Console]::In.ReadToEnd()): [Console]::In decodes stdin via
20
- # [Console]::InputEncoding, which on Windows PowerShell 5.1 defaults to the OEM
21
- # codepage (e.g. CP866 on Russian Windows) so Cyrillic/non-ASCII in the JSON
22
- # payload is mangled at READ time, before we ever re-encode to UTF-8 on the way
23
- # to node. Reading OpenStandardInput() as bytes and decoding UTF-8 ourselves
24
- # bypasses the console codepage entirely. (The 0.8.102 fix only covered the WRITE
25
- # side; the read side still mangled — observed live as mojibake'd prompts.)
14
+ # Payload: read it as RAW BYTES straight off the process's stdin handle and forward
15
+ # them to node unchanged. Do NOT read it as a string ($input / [Console]::In):
16
+ # Windows PowerShell 5.1 decodes stdin with the console's OEM codepage (e.g. cp866 on
17
+ # RU Windows), which corrupts the UTF-8 BOM and every non-ASCII byte (Cyrillic prompts,
18
+ # accented paths) BEFORE we can forward it — capture.js then sees mangled bytes (the BOM
19
+ # arrives as "´╗┐", Cyrillic as garbage) and drops the event as malformed JSON. Reading
20
+ # OpenStandardInput is byte-exact regardless of codepage. (Both Cursor and Claude Code
21
+ # pipe the payload to this process's OS stdin, so one raw read covers both.)
26
22
  #
27
23
  # Args: $args[0] = node path, $args[1] = capture.js path.
28
24
 
@@ -31,13 +27,15 @@ try {
31
27
  $node = $args[0]
32
28
  $capture = $args[1]
33
29
 
34
- $payload = @($input) -join "`n"
35
- if ([string]::IsNullOrEmpty($payload)) {
36
- $stdin = [Console]::OpenStandardInput()
37
- $ms = New-Object System.IO.MemoryStream
38
- $stdin.CopyTo($ms)
39
- $payload = [System.Text.Encoding]::UTF8.GetString($ms.ToArray())
40
- }
30
+ # Read ONLY via OpenStandardInput — and never reference the automatic $input
31
+ # variable anywhere in this script: merely mentioning $input makes PowerShell
32
+ # pre-drain the pipeline's stdin into it, which starves this byte-exact read (it
33
+ # then gets 0 bytes and the payload is lost). Verified live: with no $input
34
+ # reference, this reads the caller's UTF-8 stdin byte-for-byte (BOM + Cyrillic
35
+ # intact); with one, the bytes arrive OEM-mangled.
36
+ $ms = New-Object System.IO.MemoryStream
37
+ [Console]::OpenStandardInput().CopyTo($ms)
38
+ $bytes = $ms.ToArray()
41
39
 
42
40
  $psi = New-Object System.Diagnostics.ProcessStartInfo
43
41
  $psi.FileName = $node
@@ -47,10 +45,8 @@ try {
47
45
  $psi.RedirectStandardInput = $true
48
46
 
49
47
  $proc = [System.Diagnostics.Process]::Start($psi)
50
- # Write the payload as raw UTF-8 bytes to node's stdin (capture.js reads UTF-8). Going
51
- # through BaseStream avoids the StreamWriter's default codepage on Windows PowerShell
52
- # 5.1 mangling non-ASCII content (e.g. Cyrillic prompts, accented paths).
53
- $bytes = [System.Text.Encoding]::UTF8.GetBytes($payload)
48
+ # Write the raw payload bytes straight to node's stdin via BaseStream — no re-encoding,
49
+ # so the UTF-8 the caller sent reaches capture.js byte-for-byte.
54
50
  $proc.StandardInput.BaseStream.Write($bytes, 0, $bytes.Length)
55
51
  $proc.StandardInput.BaseStream.Flush()
56
52
  $proc.StandardInput.Close()
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';
@@ -1317,9 +1319,37 @@ export function shouldSpawnSender(lockPath = join(SENDING_DIR, '.sender.lock'),
1317
1319
  return true;
1318
1320
  }
1319
1321
 
1320
- export function trySpawnSender() {
1321
- if (!shouldSpawnSender()) return;
1322
- const senderPath = join(__dirname, 'sender.js');
1322
+ // Decide HOW to launch the background sender — pure, so it's unit-testable.
1323
+ // - Windows + run-hidden.vbs present → wscript.exe run-hidden.vbs <node> <sender>.
1324
+ // wscript is GUI-subsystem (no console) and its Shell.Run(...,0,False) starts the
1325
+ // sender hidden AND independent of the hook's process tree → windowless (no flash)
1326
+ // AND surviving (still ships). `fallback` lets the caller retry detached if wscript
1327
+ // is blocked (corporate AV/policy), trading the flash for not losing data.
1328
+ // - Everywhere else (and the Windows no-vbs fallback) → a direct detached node spawn.
1329
+ // `detached` is load-bearing: it breaks the sender out of the hook's process tree so
1330
+ // it survives the teardown — but on Windows it pops a console window (the flash).
1331
+ export function senderSpawnPlan({ platform, execPath, senderPath, vbsPath, vbsExists }) {
1332
+ if (platform === 'win32' && vbsExists) {
1333
+ return {
1334
+ command: 'wscript.exe',
1335
+ args: [vbsPath, execPath, senderPath],
1336
+ // detached: break wscript out of the hook's process group so it isn't killed
1337
+ // when the hook's process tree is torn down before Shell.Run finishes launching
1338
+ // the sender (which would silently drop the event). wscript is GUI-subsystem, so
1339
+ // detached adds no console window — still no flash.
1340
+ options: { detached: true, windowsHide: true, stdio: 'ignore' },
1341
+ fallback: true,
1342
+ };
1343
+ }
1344
+ return {
1345
+ command: execPath,
1346
+ args: [senderPath],
1347
+ options: { detached: true, stdio: 'ignore', windowsHide: true },
1348
+ fallback: false,
1349
+ };
1350
+ }
1351
+
1352
+ function spawnSenderDetached(senderPath) {
1323
1353
  const child = spawn(process.execPath, [senderPath], {
1324
1354
  detached: true,
1325
1355
  stdio: 'ignore',
@@ -1329,6 +1359,107 @@ export function trySpawnSender() {
1329
1359
  child.unref();
1330
1360
  }
1331
1361
 
1362
+ export function trySpawnSender() {
1363
+ if (!shouldSpawnSender()) return;
1364
+ const senderPath = join(__dirname, 'sender.js');
1365
+ const vbsPath = join(__dirname, 'run-hidden.vbs');
1366
+ const plan = senderSpawnPlan({
1367
+ platform: process.platform,
1368
+ execPath: process.execPath,
1369
+ senderPath,
1370
+ vbsPath,
1371
+ vbsExists: existsSync(vbsPath),
1372
+ });
1373
+ try {
1374
+ const child = spawn(plan.command, plan.args, plan.options);
1375
+ // wscript blocked (AV/policy) → fall back to the detached spawn so we keep shipping.
1376
+ child.on('error', () => { if (plan.fallback) spawnSenderDetached(senderPath); });
1377
+ child.unref();
1378
+ } catch {
1379
+ if (plan.fallback) spawnSenderDetached(senderPath);
1380
+ }
1381
+ }
1382
+
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
+ }
1332
1463
 
1333
1464
  // =============================================================================
1334
1465
  // Main
@@ -1386,6 +1517,12 @@ async function main() {
1386
1517
  // hook invocation on the same machine.
1387
1518
  const primary = events[0];
1388
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
+
1389
1526
  // Filter by monitored projects (if configured) — based on the primary event.
1390
1527
  // If the primary is filtered out, drop the entire batch (the per-call events
1391
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;
@@ -0,0 +1,24 @@
1
+ ' AI Lens — hidden + detached process launcher (Windows only).
2
+ '
3
+ ' Why this exists: capture.js must start the background sender after every event,
4
+ ' but Node's spawn can't do both things at once on Windows:
5
+ ' - spawn({ detached: true }) -> survives the hook's process-tree teardown, BUT
6
+ ' pops a console window even with windowsHide (a flash on every event);
7
+ ' - spawn({ detached: false }) -> no window, BUT the sender is killed together with
8
+ ' the hook's process tree before it can ship.
9
+ ' wscript.exe is GUI-subsystem (it never allocates a console), and WshShell.Run with
10
+ ' window style 0 (hidden) + bWaitOnReturn=False launches the target via ShellExecute
11
+ ' as an INDEPENDENT process — so the sender is both windowless AND survives. No flash,
12
+ ' capture still ships.
13
+ '
14
+ ' Args: arg(0) = executable (node), arg(1..n) = its arguments. Each is re-quoted so
15
+ ' paths with spaces (e.g. C:\Program Files\nodejs\node.exe) survive.
16
+ Option Explicit
17
+ Dim args, cmd, i
18
+ Set args = WScript.Arguments
19
+ If args.Count = 0 Then WScript.Quit 0
20
+ cmd = ""
21
+ For i = 0 To args.Count - 1
22
+ cmd = cmd & """" & args(i) & """ "
23
+ Next
24
+ CreateObject("WScript.Shell").Run cmd, 0, False
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.105",
3
+ "version": "0.8.108",
4
4
  "type": "module",
5
5
  "description": "Centralized session analytics for AI coding tools",
6
6
  "bin": {