ai-lens 0.8.93 → 0.8.95
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 +8 -0
- package/cli/hooks.js +93 -170
- package/cli/import/claude-code.js +25 -0
- package/cli/init.js +63 -41
- 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
|
+
defcd78
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
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.95 — 2026-06-17
|
|
6
|
+
- fix: on Windows, Cursor hooks no longer flash a console window on every event (including new-session start) — the hook command is now wrapped in `conhost.exe --headless` (Windows 10 1809+), the same windowless form Claude Code hooks already use. Older Windows builds and macOS/Linux are unchanged. Re-run `ai-lens init` to apply
|
|
7
|
+
- feat: `ai-lens init` now warns when the configured `projects` filter does not cover the workspace you're setting up — a stale filter silently drops all capture (hooks look configured but record nothing)
|
|
8
|
+
- feat: the Claude Code history-import offer now previews how much there is to import ("Found N sessions from A to B") before asking, instead of a bare yes/no
|
|
9
|
+
|
|
10
|
+
## 0.8.94 — 2026-06-16
|
|
11
|
+
- 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)
|
|
12
|
+
|
|
5
13
|
## 0.8.93 — 2026-06-15
|
|
6
14
|
- 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
15
|
|
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
|
/**
|
|
@@ -458,9 +375,13 @@ export function cursorCaptureCommand(opts = {}) {
|
|
|
458
375
|
// hint when building the underlying command (avoids the cmd.exe `call` prefix
|
|
459
376
|
// which PowerShell doesn't understand).
|
|
460
377
|
const shell = platform === 'win32' ? 'powershell' : null;
|
|
378
|
+
// windowless: on Windows ≥1809 wrap node in `conhost.exe --headless` so Cursor
|
|
379
|
+
// doesn't flash a console window on every event (incl. session start). `& ` still
|
|
380
|
+
// works in front of conhost; the detector strips `& ` then the conhost prefix, so
|
|
381
|
+
// the form stays recognised as current (no churn). Mac/old-Windows: unchanged.
|
|
461
382
|
const cmd = customPath != null
|
|
462
|
-
? captureCommand({
|
|
463
|
-
: captureCommand({ useTilde: effectiveUseTilde, ctx, shell });
|
|
383
|
+
? captureCommand({ rawPath: true, customPath, windowless: true, ctx, shell })
|
|
384
|
+
: captureCommand({ useTilde: effectiveUseTilde, windowless: true, ctx, shell });
|
|
464
385
|
return platform === 'win32' ? `& ${cmd}` : cmd;
|
|
465
386
|
}
|
|
466
387
|
|
|
@@ -480,37 +401,20 @@ export function listClientFiles(sourceDir = join(__dirname, '..', 'client')) {
|
|
|
480
401
|
}
|
|
481
402
|
|
|
482
403
|
/**
|
|
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.
|
|
404
|
+
* Copy client/*.js to ~/.ai-lens/client/ (default global install). No launcher is
|
|
405
|
+
* written any more (ADR 0003) — the hook command bakes the absolute node path itself,
|
|
406
|
+
* so installClientFiles no longer needs a node path. `writeLauncher` opt is accepted
|
|
407
|
+
* and ignored for back-compat with older callers/tests.
|
|
491
408
|
*
|
|
492
409
|
* @param {object} [opts]
|
|
493
410
|
* @param {string} [opts.sourceDir]
|
|
494
411
|
* @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
412
|
*/
|
|
499
413
|
export function installClientFiles(opts = {}) {
|
|
500
414
|
const {
|
|
501
415
|
sourceDir = join(__dirname, '..', 'client'),
|
|
502
416
|
clientDir = CLIENT_INSTALL_DIR,
|
|
503
|
-
platform = process.platform,
|
|
504
|
-
writeLauncher: shouldWriteLauncher = true,
|
|
505
417
|
} = 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
418
|
|
|
515
419
|
mkdirSync(clientDir, { recursive: true });
|
|
516
420
|
|
|
@@ -531,18 +435,15 @@ export function installClientFiles(opts = {}) {
|
|
|
531
435
|
join(clientDir, 'version.json'),
|
|
532
436
|
JSON.stringify({ version, commit, packageRoot: PKG_ROOT }) + '\n',
|
|
533
437
|
);
|
|
534
|
-
|
|
535
|
-
if (shouldWriteLauncher) {
|
|
536
|
-
writeLauncher({ clientDir, nodePath: nodeResolution.path, platform });
|
|
537
|
-
}
|
|
538
438
|
}
|
|
539
439
|
|
|
540
440
|
/**
|
|
541
|
-
* Remove installed client files from ~/.ai-lens/client
|
|
441
|
+
* Remove installed client files from the client dir (default ~/.ai-lens/client/).
|
|
442
|
+
* @param {string} [clientDir] — override for tests so they never rm the real install.
|
|
542
443
|
*/
|
|
543
|
-
export function removeClientFiles() {
|
|
544
|
-
if (existsSync(
|
|
545
|
-
rmSync(
|
|
444
|
+
export function removeClientFiles(clientDir = CLIENT_INSTALL_DIR) {
|
|
445
|
+
if (existsSync(clientDir)) {
|
|
446
|
+
rmSync(clientDir, { recursive: true, force: true });
|
|
546
447
|
}
|
|
547
448
|
}
|
|
548
449
|
|
|
@@ -562,6 +463,18 @@ const CLAUDE_HOOK_SPEC = {
|
|
|
562
463
|
PreCompact: { matcher: '' },
|
|
563
464
|
SubagentStart: { matcher: '' },
|
|
564
465
|
SubagentStop: { matcher: '' },
|
|
466
|
+
// Observability events — captured + forwarded, not yet wired into analysis.
|
|
467
|
+
// All fire as Claude Code `command` hooks with an empty matcher (verified
|
|
468
|
+
// against live settings.json). Kept metric-neutral server-side via the
|
|
469
|
+
// exclusion sets in server/utils/event-types.js.
|
|
470
|
+
StopFailure: { matcher: '' },
|
|
471
|
+
Notification: { matcher: '' },
|
|
472
|
+
PermissionRequest: { matcher: '' },
|
|
473
|
+
PostCompact: { matcher: '' },
|
|
474
|
+
TaskCreated: { matcher: '' },
|
|
475
|
+
TaskCompleted: { matcher: '' },
|
|
476
|
+
InstructionsLoaded: { matcher: '' },
|
|
477
|
+
UserPromptExpansion: { matcher: '' },
|
|
565
478
|
};
|
|
566
479
|
|
|
567
480
|
const CURSOR_HOOK_NAMES = [
|
|
@@ -606,7 +519,7 @@ function memoizeCmd(produce) {
|
|
|
606
519
|
* probes node candidates by itself.
|
|
607
520
|
*/
|
|
608
521
|
export function makeClaudeHookDefs(ctx = null, _mode = 'global') {
|
|
609
|
-
const getCmd = memoizeCmd(() => captureCommand({
|
|
522
|
+
const getCmd = memoizeCmd(() => captureCommand({ rawPath: true, windowless: true, ctx }));
|
|
610
523
|
const defs = {};
|
|
611
524
|
for (const [name, spec] of Object.entries(CLAUDE_HOOK_SPEC)) {
|
|
612
525
|
defs[name] = () => ({ matcher: spec.matcher, hooks: [{ type: 'command', command: getCmd() }] });
|
|
@@ -654,7 +567,7 @@ export function makeCodexHookDefs(ctx = null, mode = 'global') {
|
|
|
654
567
|
* @param {object} [ctx]
|
|
655
568
|
*/
|
|
656
569
|
export function getClaudeCodeHookDefsWithPath(capturePath, ctx = null) {
|
|
657
|
-
const getCmd = memoizeCmd(() => captureCommand({ rawPath: true, customPath: capturePath, ctx }));
|
|
570
|
+
const getCmd = memoizeCmd(() => captureCommand({ rawPath: true, customPath: capturePath, windowless: true, ctx }));
|
|
658
571
|
const defs = {};
|
|
659
572
|
for (const [name, spec] of Object.entries(CLAUDE_HOOK_SPEC)) {
|
|
660
573
|
defs[name] = () => ({ matcher: spec.matcher, hooks: [{ type: 'command', command: getCmd() }] });
|
|
@@ -674,7 +587,7 @@ export function getClaudeCodeHookDefsWithPath(capturePath, ctx = null) {
|
|
|
674
587
|
* @param {object} [ctx]
|
|
675
588
|
*/
|
|
676
589
|
export function getClaudeCodeHookDefsWithProjectDir(relPath, ctx = null) {
|
|
677
|
-
const getCmd = memoizeCmd(() => captureCommand({ projectDirRelPath: relPath, ctx }));
|
|
590
|
+
const getCmd = memoizeCmd(() => captureCommand({ projectDirRelPath: relPath, windowless: true, ctx }));
|
|
678
591
|
const defs = {};
|
|
679
592
|
for (const [name, spec] of Object.entries(CLAUDE_HOOK_SPEC)) {
|
|
680
593
|
defs[name] = () => ({ matcher: spec.matcher, hooks: [{ type: 'command', command: getCmd() }] });
|
|
@@ -860,6 +773,14 @@ export function _parseHookCommand(cmd) {
|
|
|
860
773
|
prefix = 'win-claude';
|
|
861
774
|
rest = rest.slice(5).trim();
|
|
862
775
|
}
|
|
776
|
+
// Windowless wrapper (Windows Claude, ADR 0003): conhost.exe --headless <node> <path>.
|
|
777
|
+
// Strip it like the other prefixes so the residue parses as the captureJs form and
|
|
778
|
+
// the existing absolute-node GUI-safe check applies unchanged.
|
|
779
|
+
const conhostMatch = rest.match(CONHOST_HEADLESS_PREFIX_RE);
|
|
780
|
+
if (conhostMatch) {
|
|
781
|
+
prefix = 'win-headless';
|
|
782
|
+
rest = rest.slice(conhostMatch[0].length).trim();
|
|
783
|
+
}
|
|
863
784
|
|
|
864
785
|
const tokens = tokenizeHookCommand(rest);
|
|
865
786
|
if (tokens.length === 0) {
|
|
@@ -919,18 +840,15 @@ function normalizePath(p) {
|
|
|
919
840
|
}
|
|
920
841
|
|
|
921
842
|
// 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
|
-
//
|
|
843
|
+
// if it runs capture.js with an ABSOLUTE node path baked in (optionally behind the
|
|
844
|
+
// conhost.exe --headless windowless wrapper, which _parseHookCommand strips). Bare
|
|
845
|
+
// `node` and `/usr/bin/env node` are NOT GUI-safe (they depend on node being on PATH,
|
|
846
|
+
// which GUI Cursor/Claude often lack). The old run.sh/run.cmd launcher is NO LONGER
|
|
847
|
+
// recognised here (ADR 0003 removed it) — it is therefore reported `outdated`, so
|
|
848
|
+
// `/setup` migrates an existing launcher install to the unified form. (`isAiLensCommand`
|
|
849
|
+
// still classifies run.* as ai-lens so `remove`/strip cleans up stale launchers.)
|
|
929
850
|
export function isGuiSafeHookCommand(cmd) {
|
|
930
851
|
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
852
|
// capture.js form: GUI-safe only when the node binary is an absolute path
|
|
935
853
|
// (e.g. /opt/homebrew/bin/node), not bare `node` or an env shim.
|
|
936
854
|
const p = _parseHookCommand(cmd);
|
|
@@ -981,6 +899,11 @@ export function isWrongPlatformProjectDirCommand(cmd, platform = process.platfor
|
|
|
981
899
|
return !n.includes(correctVar);
|
|
982
900
|
}
|
|
983
901
|
|
|
902
|
+
// Leading `conhost.exe --headless ` windowless wrapper, with the trailing space so a
|
|
903
|
+
// strip leaves the wrapped `<node> <path>` command. Single source of truth shared by
|
|
904
|
+
// _parseHookCommand and status.js (avoids the regex drifting between the two).
|
|
905
|
+
export const CONHOST_HEADLESS_PREFIX_RE = /^conhost(?:\.exe)?\s+--headless\s+/i;
|
|
906
|
+
|
|
984
907
|
// `conhost.exe --headless …` windowless wrapper (Windows). Tolerates an optional
|
|
985
908
|
// `.exe` and arbitrary spacing between the two tokens.
|
|
986
909
|
export function isConhostHeadlessCommand(cmd) {
|
|
@@ -92,6 +92,31 @@ export function resolveCutoff({ days, since, from }, now = new Date()) {
|
|
|
92
92
|
return new Date(now.getTime() - d * DAY_MS).toISOString();
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Cheap preview for the init import offer — how many local transcripts (≈ sessions)
|
|
97
|
+
* fall within the window and their date span, by file mtime (no file reads). Lets
|
|
98
|
+
* init show "Found N sessions from A to B" before asking to import.
|
|
99
|
+
* @returns {{ count: number, earliest: string|null, latest: string|null }}
|
|
100
|
+
*/
|
|
101
|
+
export function previewClaudeCode({ days = 90, dir = PROJECTS_DIR } = {}, now = new Date()) {
|
|
102
|
+
if (!existsSync(dir)) return { count: 0, earliest: null, latest: null };
|
|
103
|
+
const cutoffMs = Date.parse(resolveCutoff({ days }, now));
|
|
104
|
+
let count = 0, min = Infinity, max = -Infinity;
|
|
105
|
+
for (const f of walkJsonl(dir)) {
|
|
106
|
+
let m;
|
|
107
|
+
try { m = statSync(f).mtimeMs; } catch { continue; }
|
|
108
|
+
if (m < cutoffMs) continue;
|
|
109
|
+
count++;
|
|
110
|
+
if (m < min) min = m;
|
|
111
|
+
if (m > max) max = m;
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
count,
|
|
115
|
+
earliest: count ? new Date(min).toISOString() : null,
|
|
116
|
+
latest: count ? new Date(max).toISOString() : null,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
95
120
|
/**
|
|
96
121
|
* Resolve the INCLUSIVE lower bound from `--from` (or null when open-ended).
|
|
97
122
|
* Unlike the file-level `cutoff` (which only decides whether to read a file),
|
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');
|
|
@@ -593,6 +596,27 @@ export default async function init() {
|
|
|
593
596
|
info(' Tracking: all projects');
|
|
594
597
|
}
|
|
595
598
|
|
|
599
|
+
// Loud guard: a narrow `projects` filter that does NOT cover this workspace silently
|
|
600
|
+
// drops ALL capture (project_filter) — hooks look configured but record nothing. This
|
|
601
|
+
// bites when init inherits a stale `projects` from an old config (a leftover test path).
|
|
602
|
+
// Warn against the cwd (the workspace init is being run from).
|
|
603
|
+
if (projects) {
|
|
604
|
+
const cwd = resolve(process.cwd());
|
|
605
|
+
const norm = (p) => p.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
|
|
606
|
+
const cwdN = norm(cwd);
|
|
607
|
+
const covered = projects.split(',').map(norm).some(m => m && (cwdN === m || cwdN.startsWith(m + '/')));
|
|
608
|
+
if (!covered) {
|
|
609
|
+
blank();
|
|
610
|
+
warn(` ⚠ Projects filter does not include this workspace — events from here will be DROPPED.`);
|
|
611
|
+
warn(` filter: ${projects}`);
|
|
612
|
+
warn(` here: ${cwd}`);
|
|
613
|
+
info(` Capture will look configured but record nothing. Fix: re-run with \`--projects all\``);
|
|
614
|
+
info(` (track everything), or edit ~/.ai-lens/config.json and remove/adjust "projects".`);
|
|
615
|
+
info(` (Ignore if you deliberately track only other paths.)`);
|
|
616
|
+
blank();
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
596
620
|
// Build new config in memory — saved after "Proceed?" confirmation
|
|
597
621
|
const newConfig = { ...currentConfig, serverUrl, projects };
|
|
598
622
|
|
|
@@ -601,34 +625,12 @@ export default async function init() {
|
|
|
601
625
|
if (!flags.useRepoPath) {
|
|
602
626
|
heading('Installing client files...');
|
|
603
627
|
try {
|
|
604
|
-
installClientFiles({
|
|
605
|
-
nodeResolution,
|
|
606
|
-
platform: process.platform,
|
|
607
|
-
writeLauncher: willWriteHooks,
|
|
608
|
-
});
|
|
628
|
+
installClientFiles({ platform: process.platform });
|
|
609
629
|
success(' Copied client files to ~/.ai-lens/client/');
|
|
610
|
-
if (willWriteHooks) {
|
|
611
|
-
detail(' Wrote per-machine launcher (run.sh / run.cmd).');
|
|
612
|
-
}
|
|
613
630
|
} catch (err) {
|
|
614
631
|
error(` Failed to install client files: ${err.message}`);
|
|
615
632
|
return;
|
|
616
633
|
}
|
|
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
634
|
} else {
|
|
633
635
|
detail(' Skipping client install (--use-repo-path: using package copy).');
|
|
634
636
|
}
|
|
@@ -994,9 +996,11 @@ export default async function init() {
|
|
|
994
996
|
// the hook silently never fires (cmd.exe vs sh; upstream anthropic/claude-code#24710
|
|
995
997
|
// closed as not planned). When the committed form doesn't match this OS, mirror the
|
|
996
998
|
// hooks into .claude/settings.local.json — Claude Code's per-machine layer
|
|
997
|
-
// (auto-gitignored, merged with settings.json at runtime).
|
|
998
|
-
//
|
|
999
|
-
|
|
999
|
+
// (auto-gitignored, merged with settings.json at runtime). Only for the
|
|
1000
|
+
// clone-with-committed-hooks case: under --project-hooks we already wrote the
|
|
1001
|
+
// OS-correct form straight into settings.local.json above, so mirroring the
|
|
1002
|
+
// committed file on top would be redundant (and racy against that write).
|
|
1003
|
+
if (!flags.noHooks && !flags.projectHooks) {
|
|
1000
1004
|
try {
|
|
1001
1005
|
const overlayProjectRoot = resolve(process.cwd());
|
|
1002
1006
|
const overlayClaudeTool = getClaudeCodeToolConfig(overlayProjectRoot, 'Claude Code (project)', ctx);
|
|
@@ -1273,10 +1277,28 @@ async function maybeOfferImportHistory(flags) {
|
|
|
1273
1277
|
if (flags.noImport) return;
|
|
1274
1278
|
if (!existsSync(join(homedir(), '.claude', 'projects'))) return;
|
|
1275
1279
|
|
|
1280
|
+
// Preview so the offer isn't a blind yes/no: count of local transcripts (≈ sessions)
|
|
1281
|
+
// and their date span within the 90d window (cheap mtime scan, no file reads).
|
|
1282
|
+
let preview = null;
|
|
1283
|
+
try {
|
|
1284
|
+
const mod = await import('./import/claude-code.js');
|
|
1285
|
+
if (mod.previewClaudeCode) preview = mod.previewClaudeCode({ days: 90 });
|
|
1286
|
+
} catch { /* preview is best-effort */ }
|
|
1287
|
+
if (preview && preview.count === 0) {
|
|
1288
|
+
info(' No local Claude Code history in the last 90 days — nothing to import.');
|
|
1289
|
+
return;
|
|
1290
|
+
}
|
|
1291
|
+
const day = (iso) => (iso || '').slice(0, 10);
|
|
1292
|
+
const previewLine = preview
|
|
1293
|
+
? `Found ${preview.count} Claude Code session${preview.count === 1 ? '' : 's'} from ${day(preview.earliest)} to ${day(preview.latest)} (last 90d).`
|
|
1294
|
+
: 'Local Claude Code history found.';
|
|
1295
|
+
|
|
1276
1296
|
let run = flags.importHistory || flags.yes;
|
|
1277
|
-
if (
|
|
1297
|
+
if (run) {
|
|
1298
|
+
info(` ${previewLine}`);
|
|
1299
|
+
} else {
|
|
1278
1300
|
try {
|
|
1279
|
-
const answer = (await ask(
|
|
1301
|
+
const answer = (await ask(`${previewLine} Import now? (Y/n) `)).toLowerCase();
|
|
1280
1302
|
run = answer === '' || answer === 'y' || answer === 'yes';
|
|
1281
1303
|
} catch { run = false; }
|
|
1282
1304
|
}
|
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
|
}
|