ai-lens 0.8.92 → 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 +6 -0
- package/cli/hooks.js +122 -165
- 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,12 @@
|
|
|
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
|
+
|
|
8
|
+
## 0.8.93 — 2026-06-15
|
|
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
|
|
10
|
+
|
|
5
11
|
## 0.8.92 — 2026-06-11
|
|
6
12
|
- fix: when global `~/.claude` hooks are active, `ai-lens init` no longer also installs the `.claude/settings.local.json` platform overlay (and removes a previously-written one) — global hooks already capture every project, and registering both made every hook event fire capture twice
|
|
7
13
|
- improve: `ai-lens status` shows committed other-OS project hooks as healthy ("capture covered by global hooks") when global hooks are doing the work, instead of a false warning
|
package/cli/hooks.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, lstatSync, readFileSync, writeFileSync, copyFileSync, renameSync, mkdirSync, rmSync, unlinkSync, chmodSync, readdirSync } from 'node:fs';
|
|
2
2
|
import { join, dirname } from 'node:path';
|
|
3
|
-
import { homedir } from 'node:os';
|
|
3
|
+
import { homedir, release } from 'node:os';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { execFileSync } from 'node:child_process';
|
|
6
6
|
|
|
@@ -230,78 +230,31 @@ export function findStableNodePath({
|
|
|
230
230
|
return { path: execPath, stable: false, source: 'execPathFallback' };
|
|
231
231
|
}
|
|
232
232
|
|
|
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.
|
|
237
|
+
|
|
233
238
|
// ---------------------------------------------------------------------------
|
|
234
|
-
//
|
|
239
|
+
// Hook command construction
|
|
235
240
|
// ---------------------------------------------------------------------------
|
|
236
241
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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');
|
|
242
|
+
// `conhost.exe --headless` runs a console program with no visible window — the
|
|
243
|
+
// fix for Windows hooks flashing/stealing focus on every event (Claude Code spawns
|
|
244
|
+
// the hook process without CREATE_NO_WINDOW; upstream anthropics/claude-code#61051).
|
|
245
|
+
// Unlike wscript/`-WindowStyle Hidden`, it keeps stdin wired, so capture.js still
|
|
246
|
+
// receives the hook payload. `--headless` requires the modern conhost shipped in
|
|
247
|
+
// Windows 10 1809 (build 17763); on older builds the flag errors, so we gate on it
|
|
248
|
+
// and fall back to the bare-node form (still flashes, but captures).
|
|
249
|
+
const WIN_HEADLESS_MIN_BUILD = 17763;
|
|
269
250
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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;
|
|
251
|
+
export function supportsConhostHeadless(osRelease = release()) {
|
|
252
|
+
// os.release() on Windows looks like '10.0.22631'. Build = the third segment.
|
|
253
|
+
const m = /^\d+\.\d+\.(\d+)/.exec(String(osRelease || ''));
|
|
254
|
+
if (!m) return false;
|
|
255
|
+
return Number(m[1]) >= WIN_HEADLESS_MIN_BUILD;
|
|
299
256
|
}
|
|
300
257
|
|
|
301
|
-
// ---------------------------------------------------------------------------
|
|
302
|
-
// Hook command construction
|
|
303
|
-
// ---------------------------------------------------------------------------
|
|
304
|
-
|
|
305
258
|
// Lazy default ctx for callers that don't pass one (legacy tests, TOOL_CONFIGS at
|
|
306
259
|
// import-time). Resolver isn't called at module load — only when captureCommand runs.
|
|
307
260
|
function resolveDefaultCtx() {
|
|
@@ -310,6 +263,7 @@ function resolveDefaultCtx() {
|
|
|
310
263
|
nodeResolution,
|
|
311
264
|
platform: process.platform,
|
|
312
265
|
clientDir: CLIENT_INSTALL_DIR,
|
|
266
|
+
conhost: process.platform === 'win32' && supportsConhostHeadless(),
|
|
313
267
|
};
|
|
314
268
|
}
|
|
315
269
|
|
|
@@ -332,7 +286,8 @@ export function captureCommand(opts = {}) {
|
|
|
332
286
|
if (typeof opts === 'boolean') {
|
|
333
287
|
opts = { useTilde: opts, rawPath: arguments[1], customPath: arguments[2] ?? null };
|
|
334
288
|
}
|
|
335
|
-
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
|
|
336
291
|
const ctx = opts.ctx ?? resolveDefaultCtx();
|
|
337
292
|
// Tolerate partial ctx (e.g. only nodeResolution supplied) by filling in module
|
|
338
293
|
// defaults — keeps callers from having to repeat platform/clientDir everywhere.
|
|
@@ -340,6 +295,11 @@ export function captureCommand(opts = {}) {
|
|
|
340
295
|
const clientDir = ctx.clientDir ?? CLIENT_INSTALL_DIR;
|
|
341
296
|
const nodeResolution = ctx.nodeResolution;
|
|
342
297
|
const isWin = platform === 'win32';
|
|
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());
|
|
343
303
|
// shell hint distinguishes cmd.exe (Claude Code) from PowerShell (Cursor) on
|
|
344
304
|
// Windows — the two need different escaping for paths with spaces.
|
|
345
305
|
const isPS = shell === 'powershell';
|
|
@@ -354,65 +314,49 @@ export function captureCommand(opts = {}) {
|
|
|
354
314
|
// POSIX shells use $VAR.
|
|
355
315
|
if (projectDirRelPath != null) {
|
|
356
316
|
const rel = projectDirRelPath.replace(/\\/g, '/').replace(/^\.?\/+/, '');
|
|
317
|
+
if (conhost) {
|
|
318
|
+
// Windowless form. conhost is the launched image (no cmd.exe shell), so the
|
|
319
|
+
// path must use $CLAUDE_PROJECT_DIR — Claude Code substitutes that variable
|
|
320
|
+
// itself before exec on every OS (the %VAR% form would survive unexpanded
|
|
321
|
+
// since there's no shell to expand it). Verified on Windows 11 / cc 2.1.177.
|
|
322
|
+
return `conhost.exe --headless node "$CLAUDE_PROJECT_DIR/${rel}"`;
|
|
323
|
+
}
|
|
357
324
|
const dir = isWin ? '%CLAUDE_PROJECT_DIR%' : '$CLAUDE_PROJECT_DIR';
|
|
358
325
|
return `node "${dir}/${rel}"`;
|
|
359
326
|
}
|
|
360
327
|
|
|
361
|
-
//
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
let launcherPath;
|
|
394
|
-
if (useTilde && !isWin) {
|
|
395
|
-
launcherPath = `~/.ai-lens/client/${filename}`;
|
|
396
|
-
} else {
|
|
397
|
-
launcherPath = join(clientDir, filename).replace(/\\/g, '/');
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
// POSIX: quote the launcher path unless it's the unquoted tilde form (which must
|
|
401
|
-
// remain unquoted for shell tilde expansion). rawPath (Claude Code) leaves the
|
|
402
|
-
// tilde form bare too — Claude Code passes commands to /bin/sh which expands ~.
|
|
403
|
-
if (!isWin) {
|
|
404
|
-
if (useTilde) return launcherPath; // unquoted ~/path
|
|
405
|
-
return shellEscape(launcherPath, 'linux');
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
// Windows: cmd.exe (Claude Code, rawPath=true) needs `call "..."` when the
|
|
409
|
-
// launcher path contains spaces; otherwise plain quoted path works.
|
|
410
|
-
// PowerShell (Cursor) handles `"..."` via the & prefix (added by cursorCaptureCommand).
|
|
411
|
-
const quoted = `"${launcherPath.replace(/"/g, '""')}"`;
|
|
412
|
-
if (rawPath && launcherPath.includes(' ') && !isPS) {
|
|
413
|
-
return `call ${quoted}`;
|
|
414
|
-
}
|
|
415
|
-
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}`;
|
|
416
360
|
}
|
|
417
361
|
|
|
418
362
|
/**
|
|
@@ -453,37 +397,20 @@ export function listClientFiles(sourceDir = join(__dirname, '..', 'client')) {
|
|
|
453
397
|
}
|
|
454
398
|
|
|
455
399
|
/**
|
|
456
|
-
* Copy client/*.js to ~/.ai-lens/client/
|
|
457
|
-
*
|
|
458
|
-
*
|
|
459
|
-
*
|
|
460
|
-
* want the launcher in their tmpdir can pass `writeLauncher: false`. If the
|
|
461
|
-
* caller passes `writeLauncher: true` without a `nodeResolution`, we resolve
|
|
462
|
-
* one lazily — same heuristic init.js uses — so a tests-style call like
|
|
463
|
-
* `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.
|
|
464
404
|
*
|
|
465
405
|
* @param {object} [opts]
|
|
466
406
|
* @param {string} [opts.sourceDir]
|
|
467
407
|
* @param {string} [opts.clientDir]
|
|
468
|
-
* @param {{ path: string, stable: boolean }} [opts.nodeResolution] — auto-resolved if absent when writeLauncher=true
|
|
469
|
-
* @param {string} [opts.platform]
|
|
470
|
-
* @param {boolean} [opts.writeLauncher] — default true; pass false to skip launcher
|
|
471
408
|
*/
|
|
472
409
|
export function installClientFiles(opts = {}) {
|
|
473
410
|
const {
|
|
474
411
|
sourceDir = join(__dirname, '..', 'client'),
|
|
475
412
|
clientDir = CLIENT_INSTALL_DIR,
|
|
476
|
-
platform = process.platform,
|
|
477
|
-
writeLauncher: shouldWriteLauncher = true,
|
|
478
413
|
} = opts;
|
|
479
|
-
let { nodeResolution = null } = opts;
|
|
480
|
-
|
|
481
|
-
if (shouldWriteLauncher && (!nodeResolution || !nodeResolution.path)) {
|
|
482
|
-
nodeResolution = findStableNodePath();
|
|
483
|
-
if (!nodeResolution || !nodeResolution.path) {
|
|
484
|
-
throw new Error('installClientFiles: writeLauncher=true but no node binary could be resolved');
|
|
485
|
-
}
|
|
486
|
-
}
|
|
487
414
|
|
|
488
415
|
mkdirSync(clientDir, { recursive: true });
|
|
489
416
|
|
|
@@ -504,18 +431,15 @@ export function installClientFiles(opts = {}) {
|
|
|
504
431
|
join(clientDir, 'version.json'),
|
|
505
432
|
JSON.stringify({ version, commit, packageRoot: PKG_ROOT }) + '\n',
|
|
506
433
|
);
|
|
507
|
-
|
|
508
|
-
if (shouldWriteLauncher) {
|
|
509
|
-
writeLauncher({ clientDir, nodePath: nodeResolution.path, platform });
|
|
510
|
-
}
|
|
511
434
|
}
|
|
512
435
|
|
|
513
436
|
/**
|
|
514
|
-
* 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.
|
|
515
439
|
*/
|
|
516
|
-
export function removeClientFiles() {
|
|
517
|
-
if (existsSync(
|
|
518
|
-
rmSync(
|
|
440
|
+
export function removeClientFiles(clientDir = CLIENT_INSTALL_DIR) {
|
|
441
|
+
if (existsSync(clientDir)) {
|
|
442
|
+
rmSync(clientDir, { recursive: true, force: true });
|
|
519
443
|
}
|
|
520
444
|
}
|
|
521
445
|
|
|
@@ -535,6 +459,18 @@ const CLAUDE_HOOK_SPEC = {
|
|
|
535
459
|
PreCompact: { matcher: '' },
|
|
536
460
|
SubagentStart: { matcher: '' },
|
|
537
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: '' },
|
|
538
474
|
};
|
|
539
475
|
|
|
540
476
|
const CURSOR_HOOK_NAMES = [
|
|
@@ -579,7 +515,7 @@ function memoizeCmd(produce) {
|
|
|
579
515
|
* probes node candidates by itself.
|
|
580
516
|
*/
|
|
581
517
|
export function makeClaudeHookDefs(ctx = null, _mode = 'global') {
|
|
582
|
-
const getCmd = memoizeCmd(() => captureCommand({
|
|
518
|
+
const getCmd = memoizeCmd(() => captureCommand({ rawPath: true, windowless: true, ctx }));
|
|
583
519
|
const defs = {};
|
|
584
520
|
for (const [name, spec] of Object.entries(CLAUDE_HOOK_SPEC)) {
|
|
585
521
|
defs[name] = () => ({ matcher: spec.matcher, hooks: [{ type: 'command', command: getCmd() }] });
|
|
@@ -627,7 +563,7 @@ export function makeCodexHookDefs(ctx = null, mode = 'global') {
|
|
|
627
563
|
* @param {object} [ctx]
|
|
628
564
|
*/
|
|
629
565
|
export function getClaudeCodeHookDefsWithPath(capturePath, ctx = null) {
|
|
630
|
-
const getCmd = memoizeCmd(() => captureCommand({ rawPath: true, customPath: capturePath, ctx }));
|
|
566
|
+
const getCmd = memoizeCmd(() => captureCommand({ rawPath: true, customPath: capturePath, windowless: true, ctx }));
|
|
631
567
|
const defs = {};
|
|
632
568
|
for (const [name, spec] of Object.entries(CLAUDE_HOOK_SPEC)) {
|
|
633
569
|
defs[name] = () => ({ matcher: spec.matcher, hooks: [{ type: 'command', command: getCmd() }] });
|
|
@@ -647,7 +583,7 @@ export function getClaudeCodeHookDefsWithPath(capturePath, ctx = null) {
|
|
|
647
583
|
* @param {object} [ctx]
|
|
648
584
|
*/
|
|
649
585
|
export function getClaudeCodeHookDefsWithProjectDir(relPath, ctx = null) {
|
|
650
|
-
const getCmd = memoizeCmd(() => captureCommand({ projectDirRelPath: relPath, ctx }));
|
|
586
|
+
const getCmd = memoizeCmd(() => captureCommand({ projectDirRelPath: relPath, windowless: true, ctx }));
|
|
651
587
|
const defs = {};
|
|
652
588
|
for (const [name, spec] of Object.entries(CLAUDE_HOOK_SPEC)) {
|
|
653
589
|
defs[name] = () => ({ matcher: spec.matcher, hooks: [{ type: 'command', command: getCmd() }] });
|
|
@@ -833,6 +769,14 @@ export function _parseHookCommand(cmd) {
|
|
|
833
769
|
prefix = 'win-claude';
|
|
834
770
|
rest = rest.slice(5).trim();
|
|
835
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
|
+
}
|
|
836
780
|
|
|
837
781
|
const tokens = tokenizeHookCommand(rest);
|
|
838
782
|
if (tokens.length === 0) {
|
|
@@ -892,18 +836,15 @@ function normalizePath(p) {
|
|
|
892
836
|
}
|
|
893
837
|
|
|
894
838
|
// A hook command is "GUI-safe" — i.e. survives a GUI app's minimal launchd PATH —
|
|
895
|
-
// if it
|
|
896
|
-
//
|
|
897
|
-
//
|
|
898
|
-
//
|
|
899
|
-
//
|
|
900
|
-
//
|
|
901
|
-
//
|
|
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.)
|
|
902
846
|
export function isGuiSafeHookCommand(cmd) {
|
|
903
847
|
if (!isAiLensCommand(cmd).isAiLens) return false;
|
|
904
|
-
const n = (cmd || '').replace(/\\/g, '/');
|
|
905
|
-
// Launcher: direct path, or the transitional wrapper that execs run.sh/run.cmd.
|
|
906
|
-
if (n.includes('.ai-lens/client/run.sh') || n.includes('.ai-lens/client/run.cmd')) return true;
|
|
907
848
|
// capture.js form: GUI-safe only when the node binary is an absolute path
|
|
908
849
|
// (e.g. /opt/homebrew/bin/node), not bare `node` or an env shim.
|
|
909
850
|
const p = _parseHookCommand(cmd);
|
|
@@ -945,10 +886,26 @@ function isAcceptableHookCommand(cmd) {
|
|
|
945
886
|
export function isWrongPlatformProjectDirCommand(cmd, platform = process.platform) {
|
|
946
887
|
if (!isClaudeProjectDirCommand(cmd)) return false;
|
|
947
888
|
const n = (cmd || '').replace(/\\/g, '/');
|
|
889
|
+
// The conhost windowless form is unambiguously a Windows command (conhost.exe is
|
|
890
|
+
// Windows-only) and deliberately uses $CLAUDE_PROJECT_DIR — Claude Code substitutes
|
|
891
|
+
// that itself, so it expands on Windows too. It is therefore correct on win32 and
|
|
892
|
+
// wrong (won't run) anywhere else; the %VAR% rule below doesn't apply to it.
|
|
893
|
+
if (isConhostHeadlessCommand(n)) return platform !== 'win32';
|
|
948
894
|
const correctVar = platform === 'win32' ? '%CLAUDE_PROJECT_DIR%' : '$CLAUDE_PROJECT_DIR';
|
|
949
895
|
return !n.includes(correctVar);
|
|
950
896
|
}
|
|
951
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
|
+
|
|
903
|
+
// `conhost.exe --headless …` windowless wrapper (Windows). Tolerates an optional
|
|
904
|
+
// `.exe` and arbitrary spacing between the two tokens.
|
|
905
|
+
export function isConhostHeadlessCommand(cmd) {
|
|
906
|
+
return /(^|[\\/\s])conhost(\.exe)?\s+--headless\b/i.test(String(cmd || '').replace(/\\/g, '/'));
|
|
907
|
+
}
|
|
908
|
+
|
|
952
909
|
// Whether a hook-config file is committed (tracked) in git. The anti-churn rule
|
|
953
910
|
// (treat both $ and % CLAUDE_PROJECT_DIR forms as current) exists ONLY to keep a
|
|
954
911
|
// COMMITTED cross-platform hook file from being flipped to one OS's syntax and
|
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
|
}
|