ai-lens 0.8.93 → 0.8.94
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 +1 -1
- package/CHANGELOG.md +3 -0
- package/cli/hooks.js +87 -168
- package/cli/init.js +22 -39
- package/cli/scan.js +7 -9
- package/cli/status.js +10 -3
- package/client/capture.js +69 -0
- package/package.json +1 -1
package/.commithash
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
1b043cc
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
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.94 — 2026-06-16
|
|
6
|
+
- feat: capture 8 more Claude Code events that were previously ignored — turn failures (session-limit / prompt-too-long / server-overload messages), notifications, permission requests, post-compact summaries, task created/completed, instructions loaded, and slash-command expansions. Re-run `ai-lens init` to install them; they're collected and forwarded only (no dashboard changes yet)
|
|
7
|
+
|
|
5
8
|
## 0.8.93 — 2026-06-15
|
|
6
9
|
- fix: on Windows, committed Claude Code project hooks no longer flash a console window that steals focus on every event — the hook command is wrapped in `conhost.exe --headless` (Windows 10 1809+), which runs capture with no visible window while still capturing every event. Older Windows builds and macOS/Linux are unchanged
|
|
7
10
|
|
package/cli/hooks.js
CHANGED
|
@@ -230,73 +230,10 @@ export function findStableNodePath({
|
|
|
230
230
|
return { path: execPath, stable: false, source: 'execPathFallback' };
|
|
231
231
|
}
|
|
232
232
|
|
|
233
|
-
//
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
|
|
237
|
-
/**
|
|
238
|
-
* Filename of the launcher in the client directory.
|
|
239
|
-
*/
|
|
240
|
-
export function launcherFilename(platform = process.platform) {
|
|
241
|
-
return platform === 'win32' ? 'run.cmd' : 'run.sh';
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
/**
|
|
245
|
-
* Write a per-machine launcher script with the resolved node path baked in.
|
|
246
|
-
*
|
|
247
|
-
* The launcher acts as a node-resolution shim that supports two invocation modes:
|
|
248
|
-
*
|
|
249
|
-
* 1. No arguments — run the sibling capture.js (the default install flow where
|
|
250
|
-
* both the launcher and capture.js live in ~/.ai-lens/client/).
|
|
251
|
-
* 2. Script path as $1 — run that script with the resolved node. This is the
|
|
252
|
-
* repo-path mode (e.g. meta-cursor static hooks pointing at
|
|
253
|
-
* `internal/analytics/ai-lens/client/capture.js`) where capture.js auto-
|
|
254
|
-
* updates via `git pull` and only the node binary needs per-machine baking.
|
|
255
|
-
*
|
|
256
|
-
* In both cases the launcher is independent of HOME / USERPROFILE / cwd — for
|
|
257
|
-
* mode 1 the path is derived from `dirname $0`; for mode 2 it's whatever the
|
|
258
|
-
* caller passes through (typically a workspace-relative path that resolves
|
|
259
|
-
* against the cwd Cursor/Claude Code set when firing the hook).
|
|
260
|
-
*
|
|
261
|
-
* @param {object} opts
|
|
262
|
-
* @param {string} opts.clientDir — directory where the launcher (and capture.js) live
|
|
263
|
-
* @param {string} opts.nodePath — absolute path to the node binary to bake in
|
|
264
|
-
* @param {string} [opts.platform]
|
|
265
|
-
*/
|
|
266
|
-
export function writeLauncher({ clientDir = CLIENT_INSTALL_DIR, nodePath, platform = process.platform } = {}) {
|
|
267
|
-
if (!nodePath) throw new Error('writeLauncher: nodePath is required');
|
|
268
|
-
if (!clientDir) throw new Error('writeLauncher: clientDir is required');
|
|
269
|
-
|
|
270
|
-
if (platform === 'win32') {
|
|
271
|
-
const escaped = nodePath.replace(/"/g, '""');
|
|
272
|
-
const content =
|
|
273
|
-
'@echo off\r\n'
|
|
274
|
-
+ 'if "%~1"=="" goto default\r\n'
|
|
275
|
-
+ `"${escaped}" %*\r\n`
|
|
276
|
-
+ 'goto :eof\r\n'
|
|
277
|
-
+ ':default\r\n'
|
|
278
|
-
+ `"${escaped}" "%~dp0capture.js"\r\n`;
|
|
279
|
-
const target = join(clientDir, 'run.cmd');
|
|
280
|
-
writeFileSync(target, content);
|
|
281
|
-
return target;
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
const escaped = shellEscape(nodePath, 'linux'); // POSIX single-quote escape
|
|
285
|
-
const content =
|
|
286
|
-
'#!/bin/sh\n'
|
|
287
|
-
+ `# AI Lens per-machine launcher. Two modes:\n`
|
|
288
|
-
+ `# $0 → exec node on sibling capture.js (default install)\n`
|
|
289
|
-
+ `# $0 <script> → exec node on <script> with remaining args (repo-path mode)\n`
|
|
290
|
-
+ 'if [ $# -gt 0 ]; then\n'
|
|
291
|
-
+ ` exec ${escaped} "$@"\n`
|
|
292
|
-
+ 'fi\n'
|
|
293
|
-
+ 'DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)\n'
|
|
294
|
-
+ `exec ${escaped} "$DIR/capture.js"\n`;
|
|
295
|
-
const target = join(clientDir, 'run.sh');
|
|
296
|
-
writeFileSync(target, content);
|
|
297
|
-
try { chmodSync(target, 0o755); } catch { /* best effort */ }
|
|
298
|
-
return target;
|
|
299
|
-
}
|
|
233
|
+
// ADR 0003 removed the per-machine launcher (run.sh / run.cmd). The absolute node
|
|
234
|
+
// path is now baked straight into the hook command by captureCommand; there is no
|
|
235
|
+
// launcher script. `isAiLensCommand` still recognises run.* so `remove`/migration
|
|
236
|
+
// can clean up installs from before this change.
|
|
300
237
|
|
|
301
238
|
// ---------------------------------------------------------------------------
|
|
302
239
|
// Hook command construction
|
|
@@ -349,7 +286,8 @@ export function captureCommand(opts = {}) {
|
|
|
349
286
|
if (typeof opts === 'boolean') {
|
|
350
287
|
opts = { useTilde: opts, rawPath: arguments[1], customPath: arguments[2] ?? null };
|
|
351
288
|
}
|
|
352
|
-
const { useTilde = false, rawPath = false, customPath = null, shell = null, projectDirRelPath = null } = opts;
|
|
289
|
+
const { useTilde = false, rawPath = false, customPath = null, shell = null, projectDirRelPath = null, windowless = false } = opts;
|
|
290
|
+
void useTilde; // launcher form removed (ADR 0003); kept in the signature for back-compat, no effect
|
|
353
291
|
const ctx = opts.ctx ?? resolveDefaultCtx();
|
|
354
292
|
// Tolerate partial ctx (e.g. only nodeResolution supplied) by filling in module
|
|
355
293
|
// defaults — keeps callers from having to repeat platform/clientDir everywhere.
|
|
@@ -357,9 +295,11 @@ export function captureCommand(opts = {}) {
|
|
|
357
295
|
const clientDir = ctx.clientDir ?? CLIENT_INSTALL_DIR;
|
|
358
296
|
const nodeResolution = ctx.nodeResolution;
|
|
359
297
|
const isWin = platform === 'win32';
|
|
360
|
-
// Windows-only windowless wrapper (see supportsConhostHeadless).
|
|
361
|
-
//
|
|
362
|
-
|
|
298
|
+
// Windows-only windowless wrapper (see supportsConhostHeadless). Gated by the
|
|
299
|
+
// `windowless` opt so it's applied to CLAUDE forms only — Cursor/Codex never set it
|
|
300
|
+
// (no reported flash + unverified conhost+stdin for those tools; ADR 0003 §3.2).
|
|
301
|
+
// A build < 1809 falls back to the bare form. Never applied off Windows.
|
|
302
|
+
const conhost = isWin && windowless && (ctx.conhost ?? supportsConhostHeadless());
|
|
363
303
|
// shell hint distinguishes cmd.exe (Claude Code) from PowerShell (Cursor) on
|
|
364
304
|
// Windows — the two need different escaping for paths with spaces.
|
|
365
305
|
const isPS = shell === 'powershell';
|
|
@@ -385,61 +325,38 @@ export function captureCommand(opts = {}) {
|
|
|
385
325
|
return `node "${dir}/${rel}"`;
|
|
386
326
|
}
|
|
387
327
|
|
|
388
|
-
//
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
let launcherPath;
|
|
421
|
-
if (useTilde && !isWin) {
|
|
422
|
-
launcherPath = `~/.ai-lens/client/${filename}`;
|
|
423
|
-
} else {
|
|
424
|
-
launcherPath = join(clientDir, filename).replace(/\\/g, '/');
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
// POSIX: quote the launcher path unless it's the unquoted tilde form (which must
|
|
428
|
-
// remain unquoted for shell tilde expansion). rawPath (Claude Code) leaves the
|
|
429
|
-
// tilde form bare too — Claude Code passes commands to /bin/sh which expands ~.
|
|
430
|
-
if (!isWin) {
|
|
431
|
-
if (useTilde) return launcherPath; // unquoted ~/path
|
|
432
|
-
return shellEscape(launcherPath, 'linux');
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
// Windows: cmd.exe (Claude Code, rawPath=true) needs `call "..."` when the
|
|
436
|
-
// launcher path contains spaces; otherwise plain quoted path works.
|
|
437
|
-
// PowerShell (Cursor) handles `"..."` via the & prefix (added by cursorCaptureCommand).
|
|
438
|
-
const quoted = `"${launcherPath.replace(/"/g, '""')}"`;
|
|
439
|
-
if (rawPath && launcherPath.includes(' ') && !isPS) {
|
|
440
|
-
return `call ${quoted}`;
|
|
441
|
-
}
|
|
442
|
-
return quoted;
|
|
328
|
+
// Unified <node> <capture.js> form (ADR 0003 — replaces the run.sh/run.cmd launcher).
|
|
329
|
+
// capturePath = the repo capture.js (--use-repo-path, customPath given) OR the
|
|
330
|
+
// installed copy at <clientDir>/capture.js (default global install). The absolute
|
|
331
|
+
// node path is baked straight into the command — legal now that hooks are
|
|
332
|
+
// per-machine (no committed file to keep machine-agnostic).
|
|
333
|
+
if (!nodeResolution || !nodeResolution.path) {
|
|
334
|
+
throw new Error('captureCommand: nodeResolution is required');
|
|
335
|
+
}
|
|
336
|
+
const capturePath = (customPath ?? join(clientDir, 'capture.js')).replace(/\\/g, '/');
|
|
337
|
+
// The script path MUST be quoted when it contains spaces — otherwise /bin/sh,
|
|
338
|
+
// cmd.exe and PowerShell split it into argv tokens. Double-quote on Windows
|
|
339
|
+
// (works for cmd.exe + PowerShell), POSIX single-quote elsewhere.
|
|
340
|
+
const pathPart = rawPath
|
|
341
|
+
? quoteIfHasSpaces(capturePath, platform)
|
|
342
|
+
: shellEscape(capturePath, platform);
|
|
343
|
+
const nodeNorm = nodeResolution.path.replace(/\\/g, '/');
|
|
344
|
+
|
|
345
|
+
// Windowless (Claude + Windows ≥ 1809): conhost.exe is the launched image and runs
|
|
346
|
+
// node directly, so it needs neither the cmd.exe `call` nor the PowerShell `&`
|
|
347
|
+
// wrapper. stdin is forwarded (verified). The node path is always double-quoted on
|
|
348
|
+
// Windows (it can contain spaces, e.g. C:/Program Files/nodejs/node.exe).
|
|
349
|
+
if (conhost) {
|
|
350
|
+
return `conhost.exe --headless "${nodeNorm.replace(/"/g, '""')}" ${pathPart}`;
|
|
351
|
+
}
|
|
352
|
+
if (isWin && rawPath && nodeNorm.includes(' ')) {
|
|
353
|
+
const quotedNode = `"${nodeNorm.replace(/"/g, '""')}"`;
|
|
354
|
+
// PowerShell (Cursor) handles a quoted first token natively — caller prepends `& `.
|
|
355
|
+
if (isPS) return `${quotedNode} ${pathPart}`;
|
|
356
|
+
// cmd.exe (Claude Code) needs `call` to run a quoted first token.
|
|
357
|
+
return `call ${quotedNode} ${pathPart}`;
|
|
358
|
+
}
|
|
359
|
+
return `${shellEscape(nodeNorm, platform)} ${pathPart}`;
|
|
443
360
|
}
|
|
444
361
|
|
|
445
362
|
/**
|
|
@@ -480,37 +397,20 @@ export function listClientFiles(sourceDir = join(__dirname, '..', 'client')) {
|
|
|
480
397
|
}
|
|
481
398
|
|
|
482
399
|
/**
|
|
483
|
-
* Copy client/*.js to ~/.ai-lens/client/
|
|
484
|
-
*
|
|
485
|
-
*
|
|
486
|
-
*
|
|
487
|
-
* want the launcher in their tmpdir can pass `writeLauncher: false`. If the
|
|
488
|
-
* caller passes `writeLauncher: true` without a `nodeResolution`, we resolve
|
|
489
|
-
* one lazily — same heuristic init.js uses — so a tests-style call like
|
|
490
|
-
* `installClientFiles()` (no args) restores a working install.
|
|
400
|
+
* Copy client/*.js to ~/.ai-lens/client/ (default global install). No launcher is
|
|
401
|
+
* written any more (ADR 0003) — the hook command bakes the absolute node path itself,
|
|
402
|
+
* so installClientFiles no longer needs a node path. `writeLauncher` opt is accepted
|
|
403
|
+
* and ignored for back-compat with older callers/tests.
|
|
491
404
|
*
|
|
492
405
|
* @param {object} [opts]
|
|
493
406
|
* @param {string} [opts.sourceDir]
|
|
494
407
|
* @param {string} [opts.clientDir]
|
|
495
|
-
* @param {{ path: string, stable: boolean }} [opts.nodeResolution] — auto-resolved if absent when writeLauncher=true
|
|
496
|
-
* @param {string} [opts.platform]
|
|
497
|
-
* @param {boolean} [opts.writeLauncher] — default true; pass false to skip launcher
|
|
498
408
|
*/
|
|
499
409
|
export function installClientFiles(opts = {}) {
|
|
500
410
|
const {
|
|
501
411
|
sourceDir = join(__dirname, '..', 'client'),
|
|
502
412
|
clientDir = CLIENT_INSTALL_DIR,
|
|
503
|
-
platform = process.platform,
|
|
504
|
-
writeLauncher: shouldWriteLauncher = true,
|
|
505
413
|
} = opts;
|
|
506
|
-
let { nodeResolution = null } = opts;
|
|
507
|
-
|
|
508
|
-
if (shouldWriteLauncher && (!nodeResolution || !nodeResolution.path)) {
|
|
509
|
-
nodeResolution = findStableNodePath();
|
|
510
|
-
if (!nodeResolution || !nodeResolution.path) {
|
|
511
|
-
throw new Error('installClientFiles: writeLauncher=true but no node binary could be resolved');
|
|
512
|
-
}
|
|
513
|
-
}
|
|
514
414
|
|
|
515
415
|
mkdirSync(clientDir, { recursive: true });
|
|
516
416
|
|
|
@@ -531,18 +431,15 @@ export function installClientFiles(opts = {}) {
|
|
|
531
431
|
join(clientDir, 'version.json'),
|
|
532
432
|
JSON.stringify({ version, commit, packageRoot: PKG_ROOT }) + '\n',
|
|
533
433
|
);
|
|
534
|
-
|
|
535
|
-
if (shouldWriteLauncher) {
|
|
536
|
-
writeLauncher({ clientDir, nodePath: nodeResolution.path, platform });
|
|
537
|
-
}
|
|
538
434
|
}
|
|
539
435
|
|
|
540
436
|
/**
|
|
541
|
-
* Remove installed client files from ~/.ai-lens/client
|
|
437
|
+
* Remove installed client files from the client dir (default ~/.ai-lens/client/).
|
|
438
|
+
* @param {string} [clientDir] — override for tests so they never rm the real install.
|
|
542
439
|
*/
|
|
543
|
-
export function removeClientFiles() {
|
|
544
|
-
if (existsSync(
|
|
545
|
-
rmSync(
|
|
440
|
+
export function removeClientFiles(clientDir = CLIENT_INSTALL_DIR) {
|
|
441
|
+
if (existsSync(clientDir)) {
|
|
442
|
+
rmSync(clientDir, { recursive: true, force: true });
|
|
546
443
|
}
|
|
547
444
|
}
|
|
548
445
|
|
|
@@ -562,6 +459,18 @@ const CLAUDE_HOOK_SPEC = {
|
|
|
562
459
|
PreCompact: { matcher: '' },
|
|
563
460
|
SubagentStart: { matcher: '' },
|
|
564
461
|
SubagentStop: { matcher: '' },
|
|
462
|
+
// Observability events — captured + forwarded, not yet wired into analysis.
|
|
463
|
+
// All fire as Claude Code `command` hooks with an empty matcher (verified
|
|
464
|
+
// against live settings.json). Kept metric-neutral server-side via the
|
|
465
|
+
// exclusion sets in server/utils/event-types.js.
|
|
466
|
+
StopFailure: { matcher: '' },
|
|
467
|
+
Notification: { matcher: '' },
|
|
468
|
+
PermissionRequest: { matcher: '' },
|
|
469
|
+
PostCompact: { matcher: '' },
|
|
470
|
+
TaskCreated: { matcher: '' },
|
|
471
|
+
TaskCompleted: { matcher: '' },
|
|
472
|
+
InstructionsLoaded: { matcher: '' },
|
|
473
|
+
UserPromptExpansion: { matcher: '' },
|
|
565
474
|
};
|
|
566
475
|
|
|
567
476
|
const CURSOR_HOOK_NAMES = [
|
|
@@ -606,7 +515,7 @@ function memoizeCmd(produce) {
|
|
|
606
515
|
* probes node candidates by itself.
|
|
607
516
|
*/
|
|
608
517
|
export function makeClaudeHookDefs(ctx = null, _mode = 'global') {
|
|
609
|
-
const getCmd = memoizeCmd(() => captureCommand({
|
|
518
|
+
const getCmd = memoizeCmd(() => captureCommand({ rawPath: true, windowless: true, ctx }));
|
|
610
519
|
const defs = {};
|
|
611
520
|
for (const [name, spec] of Object.entries(CLAUDE_HOOK_SPEC)) {
|
|
612
521
|
defs[name] = () => ({ matcher: spec.matcher, hooks: [{ type: 'command', command: getCmd() }] });
|
|
@@ -654,7 +563,7 @@ export function makeCodexHookDefs(ctx = null, mode = 'global') {
|
|
|
654
563
|
* @param {object} [ctx]
|
|
655
564
|
*/
|
|
656
565
|
export function getClaudeCodeHookDefsWithPath(capturePath, ctx = null) {
|
|
657
|
-
const getCmd = memoizeCmd(() => captureCommand({ rawPath: true, customPath: capturePath, ctx }));
|
|
566
|
+
const getCmd = memoizeCmd(() => captureCommand({ rawPath: true, customPath: capturePath, windowless: true, ctx }));
|
|
658
567
|
const defs = {};
|
|
659
568
|
for (const [name, spec] of Object.entries(CLAUDE_HOOK_SPEC)) {
|
|
660
569
|
defs[name] = () => ({ matcher: spec.matcher, hooks: [{ type: 'command', command: getCmd() }] });
|
|
@@ -674,7 +583,7 @@ export function getClaudeCodeHookDefsWithPath(capturePath, ctx = null) {
|
|
|
674
583
|
* @param {object} [ctx]
|
|
675
584
|
*/
|
|
676
585
|
export function getClaudeCodeHookDefsWithProjectDir(relPath, ctx = null) {
|
|
677
|
-
const getCmd = memoizeCmd(() => captureCommand({ projectDirRelPath: relPath, ctx }));
|
|
586
|
+
const getCmd = memoizeCmd(() => captureCommand({ projectDirRelPath: relPath, windowless: true, ctx }));
|
|
678
587
|
const defs = {};
|
|
679
588
|
for (const [name, spec] of Object.entries(CLAUDE_HOOK_SPEC)) {
|
|
680
589
|
defs[name] = () => ({ matcher: spec.matcher, hooks: [{ type: 'command', command: getCmd() }] });
|
|
@@ -860,6 +769,14 @@ export function _parseHookCommand(cmd) {
|
|
|
860
769
|
prefix = 'win-claude';
|
|
861
770
|
rest = rest.slice(5).trim();
|
|
862
771
|
}
|
|
772
|
+
// Windowless wrapper (Windows Claude, ADR 0003): conhost.exe --headless <node> <path>.
|
|
773
|
+
// Strip it like the other prefixes so the residue parses as the captureJs form and
|
|
774
|
+
// the existing absolute-node GUI-safe check applies unchanged.
|
|
775
|
+
const conhostMatch = rest.match(CONHOST_HEADLESS_PREFIX_RE);
|
|
776
|
+
if (conhostMatch) {
|
|
777
|
+
prefix = 'win-headless';
|
|
778
|
+
rest = rest.slice(conhostMatch[0].length).trim();
|
|
779
|
+
}
|
|
863
780
|
|
|
864
781
|
const tokens = tokenizeHookCommand(rest);
|
|
865
782
|
if (tokens.length === 0) {
|
|
@@ -919,18 +836,15 @@ function normalizePath(p) {
|
|
|
919
836
|
}
|
|
920
837
|
|
|
921
838
|
// A hook command is "GUI-safe" — i.e. survives a GUI app's minimal launchd PATH —
|
|
922
|
-
// if it
|
|
923
|
-
//
|
|
924
|
-
//
|
|
925
|
-
//
|
|
926
|
-
//
|
|
927
|
-
//
|
|
928
|
-
//
|
|
839
|
+
// if it runs capture.js with an ABSOLUTE node path baked in (optionally behind the
|
|
840
|
+
// conhost.exe --headless windowless wrapper, which _parseHookCommand strips). Bare
|
|
841
|
+
// `node` and `/usr/bin/env node` are NOT GUI-safe (they depend on node being on PATH,
|
|
842
|
+
// which GUI Cursor/Claude often lack). The old run.sh/run.cmd launcher is NO LONGER
|
|
843
|
+
// recognised here (ADR 0003 removed it) — it is therefore reported `outdated`, so
|
|
844
|
+
// `/setup` migrates an existing launcher install to the unified form. (`isAiLensCommand`
|
|
845
|
+
// still classifies run.* as ai-lens so `remove`/strip cleans up stale launchers.)
|
|
929
846
|
export function isGuiSafeHookCommand(cmd) {
|
|
930
847
|
if (!isAiLensCommand(cmd).isAiLens) return false;
|
|
931
|
-
const n = (cmd || '').replace(/\\/g, '/');
|
|
932
|
-
// Launcher: direct path, or the transitional wrapper that execs run.sh/run.cmd.
|
|
933
|
-
if (n.includes('.ai-lens/client/run.sh') || n.includes('.ai-lens/client/run.cmd')) return true;
|
|
934
848
|
// capture.js form: GUI-safe only when the node binary is an absolute path
|
|
935
849
|
// (e.g. /opt/homebrew/bin/node), not bare `node` or an env shim.
|
|
936
850
|
const p = _parseHookCommand(cmd);
|
|
@@ -981,6 +895,11 @@ export function isWrongPlatformProjectDirCommand(cmd, platform = process.platfor
|
|
|
981
895
|
return !n.includes(correctVar);
|
|
982
896
|
}
|
|
983
897
|
|
|
898
|
+
// Leading `conhost.exe --headless ` windowless wrapper, with the trailing space so a
|
|
899
|
+
// strip leaves the wrapped `<node> <path>` command. Single source of truth shared by
|
|
900
|
+
// _parseHookCommand and status.js (avoids the regex drifting between the two).
|
|
901
|
+
export const CONHOST_HEADLESS_PREFIX_RE = /^conhost(?:\.exe)?\s+--headless\s+/i;
|
|
902
|
+
|
|
984
903
|
// `conhost.exe --headless …` windowless wrapper (Windows). Tolerates an optional
|
|
985
904
|
// `.exe` and arbitrary spacing between the two tokens.
|
|
986
905
|
export function isConhostHeadlessCommand(cmd) {
|
package/cli/init.js
CHANGED
|
@@ -20,9 +20,8 @@ import {
|
|
|
20
20
|
getClaudeCodeHookDefsWithPath, getClaudeCodeHookDefsWithProjectDir, getCursorHookDefsWithPath, getCodexHookDefsWithPath,
|
|
21
21
|
cleanupLegacyHooks, cleanupOppositeScope, cleanupEmptyMcpJson, addCursorMcp, removeCursorMcp,
|
|
22
22
|
checkHooksDisabled, enableHooks,
|
|
23
|
-
findStableNodePath,
|
|
23
|
+
findStableNodePath,
|
|
24
24
|
} from './hooks.js';
|
|
25
|
-
import { mkdirSync } from 'node:fs';
|
|
26
25
|
import { scanNestedClaudeProjects, summarizeNestedProjects } from './scan.js';
|
|
27
26
|
|
|
28
27
|
function ask(question) {
|
|
@@ -360,7 +359,7 @@ function getInitArgs() {
|
|
|
360
359
|
flags.useRepoPath = true;
|
|
361
360
|
break;
|
|
362
361
|
case '--install-launcher':
|
|
363
|
-
|
|
362
|
+
// ADR 0003 removed the launcher; flag accepted but inert (back-compat).
|
|
364
363
|
break;
|
|
365
364
|
case '--import':
|
|
366
365
|
flags.importHistory = true;
|
|
@@ -405,16 +404,14 @@ export default async function init() {
|
|
|
405
404
|
// --project-hooks ALWAYS writes hooks regardless of whether ~/.cursor or
|
|
406
405
|
// ~/.claude exist globally — so willWriteHooks must consider it.
|
|
407
406
|
//
|
|
408
|
-
// --install-launcher (added 0.8.69) forces launcher installation even when
|
|
409
|
-
// --no-hooks. Used by bootstrap workflows like meta-cursor where static hook
|
|
410
|
-
// templates point at ~/.ai-lens/client/run.sh but init must NOT overwrite
|
|
411
|
-
// those templates with the dynamic hook form.
|
|
412
407
|
const globalTools = detectInstalledTools();
|
|
413
408
|
const willWriteHooks = !flags.noHooks && (globalTools.length > 0 || !!flags.projectHooks);
|
|
414
|
-
const willInstallLauncher = willWriteHooks || !!flags.installLauncher;
|
|
415
409
|
let nodeResolution = null;
|
|
416
410
|
let ctx = null;
|
|
417
|
-
if (
|
|
411
|
+
if (willWriteHooks) {
|
|
412
|
+
// The hook command bakes an absolute node path (ADR 0003 — no more launcher), so
|
|
413
|
+
// resolve a stable node binary up front. A version-pinned path still works but is
|
|
414
|
+
// warned about: it breaks on a node upgrade until the next /setup re-resolves it.
|
|
418
415
|
nodeResolution = findStableNodePath();
|
|
419
416
|
if (!nodeResolution) {
|
|
420
417
|
error('Could not find any node binary on this system.');
|
|
@@ -429,9 +426,7 @@ export default async function init() {
|
|
|
429
426
|
} else {
|
|
430
427
|
detail(` Resolved node: ${nodeResolution.path} (${nodeResolution.source})`);
|
|
431
428
|
}
|
|
432
|
-
|
|
433
|
-
ctx = { nodeResolution, platform: process.platform, clientDir: join(homedir(), '.ai-lens', 'client') };
|
|
434
|
-
}
|
|
429
|
+
ctx = { nodeResolution, platform: process.platform, clientDir: join(homedir(), '.ai-lens', 'client') };
|
|
435
430
|
}
|
|
436
431
|
|
|
437
432
|
// Detect installed tools — re-detect with ctx now that it's available.
|
|
@@ -452,9 +447,17 @@ export default async function init() {
|
|
|
452
447
|
info(' Cursor hooks will be written to this project (.cursor/hooks.json).');
|
|
453
448
|
}
|
|
454
449
|
if (claudeProject) {
|
|
450
|
+
// Б (HELP-1539): Claude Code project hooks go to the gitignored per-machine
|
|
451
|
+
// overlay (.claude/settings.local.json), NEVER the committed settings.json.
|
|
452
|
+
// A committed Claude Code hook carries one OS's form and is the recurring
|
|
453
|
+
// cross-OS bug source (flash on Windows / silently-never-fires on the other
|
|
454
|
+
// OS — HELP-1169, HELP-1539). The local layer lets each machine install its
|
|
455
|
+
// own OS-correct form (windowless conhost on Windows). Cursor/Codex keep their
|
|
456
|
+
// committed project files — they don't share the cross-OS-committed problem.
|
|
457
|
+
claudeProject.configPath = join(claudeProject.dirPath, 'settings.local.json');
|
|
455
458
|
tools = tools.filter(t => t.name !== 'Claude Code');
|
|
456
459
|
tools.push(claudeProject);
|
|
457
|
-
info(' Claude Code hooks will be written to this project (.claude/settings.json).');
|
|
460
|
+
info(' Claude Code hooks will be written per-machine to this project (.claude/settings.local.json, gitignored).');
|
|
458
461
|
}
|
|
459
462
|
if (codexProject) {
|
|
460
463
|
tools = tools.filter(t => t.name !== 'Codex');
|
|
@@ -601,34 +604,12 @@ export default async function init() {
|
|
|
601
604
|
if (!flags.useRepoPath) {
|
|
602
605
|
heading('Installing client files...');
|
|
603
606
|
try {
|
|
604
|
-
installClientFiles({
|
|
605
|
-
nodeResolution,
|
|
606
|
-
platform: process.platform,
|
|
607
|
-
writeLauncher: willWriteHooks,
|
|
608
|
-
});
|
|
607
|
+
installClientFiles({ platform: process.platform });
|
|
609
608
|
success(' Copied client files to ~/.ai-lens/client/');
|
|
610
|
-
if (willWriteHooks) {
|
|
611
|
-
detail(' Wrote per-machine launcher (run.sh / run.cmd).');
|
|
612
|
-
}
|
|
613
609
|
} catch (err) {
|
|
614
610
|
error(` Failed to install client files: ${err.message}`);
|
|
615
611
|
return;
|
|
616
612
|
}
|
|
617
|
-
} else if (willInstallLauncher) {
|
|
618
|
-
// --use-repo-path with --install-launcher: keep capture.js out of the install
|
|
619
|
-
// dir (it lives in the monorepo, auto-updates via git pull) but still create
|
|
620
|
-
// the per-machine launcher so static hooks can route through it for proper
|
|
621
|
-
// node resolution. The meta-cursor bootstrap is the primary consumer.
|
|
622
|
-
heading('Installing launcher (--install-launcher) ...');
|
|
623
|
-
try {
|
|
624
|
-
const clientDir = join(homedir(), '.ai-lens', 'client');
|
|
625
|
-
mkdirSync(clientDir, { recursive: true });
|
|
626
|
-
writeLauncher({ clientDir, nodePath: nodeResolution.path, platform: process.platform });
|
|
627
|
-
success(' Wrote per-machine launcher to ~/.ai-lens/client/');
|
|
628
|
-
} catch (err) {
|
|
629
|
-
error(` Failed to write launcher: ${err.message}`);
|
|
630
|
-
return;
|
|
631
|
-
}
|
|
632
613
|
} else {
|
|
633
614
|
detail(' Skipping client install (--use-repo-path: using package copy).');
|
|
634
615
|
}
|
|
@@ -994,9 +975,11 @@ export default async function init() {
|
|
|
994
975
|
// the hook silently never fires (cmd.exe vs sh; upstream anthropic/claude-code#24710
|
|
995
976
|
// closed as not planned). When the committed form doesn't match this OS, mirror the
|
|
996
977
|
// hooks into .claude/settings.local.json — Claude Code's per-machine layer
|
|
997
|
-
// (auto-gitignored, merged with settings.json at runtime).
|
|
998
|
-
//
|
|
999
|
-
|
|
978
|
+
// (auto-gitignored, merged with settings.json at runtime). Only for the
|
|
979
|
+
// clone-with-committed-hooks case: under --project-hooks we already wrote the
|
|
980
|
+
// OS-correct form straight into settings.local.json above, so mirroring the
|
|
981
|
+
// committed file on top would be redundant (and racy against that write).
|
|
982
|
+
if (!flags.noHooks && !flags.projectHooks) {
|
|
1000
983
|
try {
|
|
1001
984
|
const overlayProjectRoot = resolve(process.cwd());
|
|
1002
985
|
const overlayClaudeTool = getClaudeCodeToolConfig(overlayProjectRoot, 'Claude Code (project)', ctx);
|
package/cli/scan.js
CHANGED
|
@@ -101,16 +101,14 @@ function classifyProject(projectDir) {
|
|
|
101
101
|
};
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
}
|
|
111
|
-
|
|
104
|
+
// AI Lens Claude hooks are per-machine / OS-specific (ADR 0003 — absolute node path,
|
|
105
|
+
// conhost.exe on Windows). They must NEVER be written into a COMMITTED settings.json
|
|
106
|
+
// (that re-introduces the cross-OS / cross-machine breakage MR !298 removed). Always
|
|
107
|
+
// install into the gitignored, per-machine settings.local.json, which Claude Code
|
|
108
|
+
// merges with settings.json at runtime — so a project's own non-ai-lens hooks in
|
|
109
|
+
// settings.json keep working alongside.
|
|
112
110
|
return {
|
|
113
|
-
status: 'missing',
|
|
111
|
+
status: hasAnyNonAiLensHooks(settings) ? 'has non-ai-lens hooks' : 'missing',
|
|
114
112
|
installTarget: 'settings.local.json',
|
|
115
113
|
existingConfig: settingsLocal,
|
|
116
114
|
};
|
package/cli/status.js
CHANGED
|
@@ -7,7 +7,7 @@ import tls from 'node:tls';
|
|
|
7
7
|
|
|
8
8
|
import { TLS_TRUST_CODES, tlsCodeOf, tlsVerdictSummary, issuerName } from '../client/tls-trust.js';
|
|
9
9
|
|
|
10
|
-
import { getVersionInfo, readLensConfig, detectInstalledTools, getCursorToolConfig, getClaudeCodeToolConfig, getCodexToolConfig, analyzeToolHooks, checkHooksDisabled, verifyCodexHookTrust, CAPTURE_PATH, TOOL_CONFIGS, isClaudeProjectDirCommand, analyzeClaudeLocalOverlay, extractProjectDirRelPath, globalClaudeHooksActive } from './hooks.js';
|
|
10
|
+
import { getVersionInfo, readLensConfig, detectInstalledTools, getCursorToolConfig, getClaudeCodeToolConfig, getCodexToolConfig, analyzeToolHooks, checkHooksDisabled, verifyCodexHookTrust, CAPTURE_PATH, TOOL_CONFIGS, isClaudeProjectDirCommand, analyzeClaudeLocalOverlay, extractProjectDirRelPath, globalClaudeHooksActive, CONHOST_HEADLESS_PREFIX_RE } from './hooks.js';
|
|
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';
|
|
@@ -167,13 +167,20 @@ function validateHookCommandPaths(tool) {
|
|
|
167
167
|
issues.push(`capture.js not found at: ${capturePath}`);
|
|
168
168
|
}
|
|
169
169
|
}
|
|
170
|
-
|
|
170
|
+
// Strip the Cursor PowerShell `& ` and the Windows windowless `conhost.exe
|
|
171
|
+
// --headless` wrappers so the FIRST remaining token is the real node binary —
|
|
172
|
+
// then flag a baked absolute node path that no longer exists (e.g. after a node
|
|
173
|
+
// upgrade moved a version-pinned path), so it surfaces instead of silently
|
|
174
|
+
// dropping events (ADR 0003 §6 / R3).
|
|
175
|
+
const cmdForNode = command
|
|
176
|
+
.replace(/^& /, '')
|
|
177
|
+
.replace(CONHOST_HEADLESS_PREFIX_RE, '');
|
|
171
178
|
if (!cmdForNode.startsWith('/usr/bin/env node')) {
|
|
172
179
|
const nodeMatch = cmdForNode.match(/^["']([^"']+)["']|^(\S+)/);
|
|
173
180
|
if (nodeMatch) {
|
|
174
181
|
const nodePath = expandTilde(nodeMatch[1] || nodeMatch[2]);
|
|
175
182
|
if (nodePath !== 'node' && !existsSync(nodePath)) {
|
|
176
|
-
issues.push(`node not found at: ${nodePath}`);
|
|
183
|
+
issues.push(`node not found at: ${nodePath} — re-run \`ai-lens init\` to re-resolve node`);
|
|
177
184
|
}
|
|
178
185
|
}
|
|
179
186
|
}
|
package/client/capture.js
CHANGED
|
@@ -99,6 +99,9 @@ export const TRUNCATION_LIMITS = {
|
|
|
99
99
|
userPrompt: 1000,
|
|
100
100
|
agentResponse: 1000,
|
|
101
101
|
agentThought: 500,
|
|
102
|
+
// Compact summaries / task descriptions can run multiple KB — keep a
|
|
103
|
+
// generous prefix in `data`; the full text always survives in `raw`.
|
|
104
|
+
summary: 2000,
|
|
102
105
|
};
|
|
103
106
|
|
|
104
107
|
export function truncate(text, maxLen) {
|
|
@@ -684,6 +687,16 @@ const CLAUDE_CODE_TYPE_MAP = {
|
|
|
684
687
|
PreCompact: 'PreCompact',
|
|
685
688
|
SubagentStart: 'SubagentStart',
|
|
686
689
|
SubagentStop: 'SubagentStop',
|
|
690
|
+
// Observability events — captured + forwarded, not yet wired into analysis.
|
|
691
|
+
// Kept metric-neutral server-side (server/utils/event-types.js exclusion sets).
|
|
692
|
+
StopFailure: 'StopFailure',
|
|
693
|
+
Notification: 'Notification',
|
|
694
|
+
PermissionRequest: 'PermissionRequest',
|
|
695
|
+
PostCompact: 'PostCompact',
|
|
696
|
+
TaskCreated: 'TaskCreated',
|
|
697
|
+
TaskCompleted: 'TaskCompleted',
|
|
698
|
+
InstructionsLoaded: 'InstructionsLoaded',
|
|
699
|
+
UserPromptExpansion: 'UserPromptExpansion',
|
|
687
700
|
};
|
|
688
701
|
|
|
689
702
|
// Tools that indicate plan mode transitions
|
|
@@ -795,6 +808,62 @@ function normalizeClaudeCode(event) {
|
|
|
795
808
|
agent_type: event.agent_type || null,
|
|
796
809
|
};
|
|
797
810
|
break;
|
|
811
|
+
// --- Observability events (captured + forwarded, not yet analyzed) ------
|
|
812
|
+
case 'StopFailure':
|
|
813
|
+
// A turn that aborted (session limit / prompt-too-long / 5xx overload /
|
|
814
|
+
// rate limit). NOTE: deliberately does NOT read the transcript delta for
|
|
815
|
+
// TokenUsage (unlike Stop/SubagentStop below) — those tokens are picked
|
|
816
|
+
// up by the next Stop in the session via the persisted offset.
|
|
817
|
+
data = {
|
|
818
|
+
error: event.error ?? null,
|
|
819
|
+
last_assistant_message: truncate(event.last_assistant_message || '', TRUNCATION_LIMITS.userPrompt),
|
|
820
|
+
};
|
|
821
|
+
break;
|
|
822
|
+
case 'Notification':
|
|
823
|
+
data = {
|
|
824
|
+
notification_type: event.notification_type ?? null,
|
|
825
|
+
message: truncate(event.message || '', TRUNCATION_LIMITS.userPrompt),
|
|
826
|
+
};
|
|
827
|
+
break;
|
|
828
|
+
case 'PermissionRequest': {
|
|
829
|
+
const reqTool = event.tool_name || event.tool || 'unknown';
|
|
830
|
+
data = {
|
|
831
|
+
tool: reqTool,
|
|
832
|
+
input: truncateToolInput(event.tool_input || event.input, reqTool),
|
|
833
|
+
permission_mode: event.permission_mode ?? null,
|
|
834
|
+
};
|
|
835
|
+
break;
|
|
836
|
+
}
|
|
837
|
+
case 'PostCompact':
|
|
838
|
+
data = {
|
|
839
|
+
trigger: event.trigger ?? null,
|
|
840
|
+
compact_summary: truncate(event.compact_summary || '', TRUNCATION_LIMITS.summary),
|
|
841
|
+
};
|
|
842
|
+
break;
|
|
843
|
+
case 'TaskCreated':
|
|
844
|
+
case 'TaskCompleted':
|
|
845
|
+
data = {
|
|
846
|
+
task_id: event.task_id ?? null,
|
|
847
|
+
task_subject: truncate(event.task_subject || '', TRUNCATION_LIMITS.userPrompt),
|
|
848
|
+
task_description: truncate(event.task_description || '', TRUNCATION_LIMITS.summary),
|
|
849
|
+
};
|
|
850
|
+
break;
|
|
851
|
+
case 'InstructionsLoaded':
|
|
852
|
+
data = {
|
|
853
|
+
file_path: event.file_path ?? null,
|
|
854
|
+
memory_type: event.memory_type ?? null,
|
|
855
|
+
load_reason: event.load_reason ?? null,
|
|
856
|
+
trigger_file_path: event.trigger_file_path ?? null,
|
|
857
|
+
};
|
|
858
|
+
break;
|
|
859
|
+
case 'UserPromptExpansion':
|
|
860
|
+
data = {
|
|
861
|
+
expansion_type: event.expansion_type ?? null,
|
|
862
|
+
command_name: event.command_name ?? null,
|
|
863
|
+
command_source: event.command_source ?? null,
|
|
864
|
+
prompt: truncate(event.prompt || '', TRUNCATION_LIMITS.userPrompt),
|
|
865
|
+
};
|
|
866
|
+
break;
|
|
798
867
|
default:
|
|
799
868
|
data = { hook: hookType };
|
|
800
869
|
}
|