ai-lens 0.8.107 → 0.8.109
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 +7 -0
- package/bin/ai-lens.js +3 -2
- package/cli/hooks.js +59 -6
- package/cli/init.js +279 -112
- package/cli/scan.js +82 -24
- package/cli/status.js +27 -6
- package/client/capture.js +88 -0
- package/client/config.js +22 -0
- package/client/sender.js +25 -0
- package/package.json +1 -1
package/.commithash
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
99dee20
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
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.109 — 2026-06-22
|
|
6
|
+
- fix: `init --yes` no longer captures every project or silently imports your local Claude history. It now scopes capture to the git root of the current folder (still `--projects all` for everything), and imports past history only when you pass `--import`.
|
|
7
|
+
|
|
8
|
+
## 0.8.108 — 2026-06-22
|
|
9
|
+
- feat: `ai-lens init --mcp-only` registers just the MCP server in Claude Code and Cursor — for when you only need to read sessions from Claude, not capture hooks. It skips hook setup, authentication, and history import. The MCP scope now mirrors how AI Lens is installed: a local/project install registers the MCP at project scope, a global install at user scope (local wins if both are present); pass `--mcp-scope` to override.
|
|
10
|
+
- change: `init --project-hooks` now also registers the MCP at project scope (Claude `local` + the project `.cursor/mcp.json`) to match where it writes hooks, instead of always registering globally. Pass `--mcp-scope` to override.
|
|
11
|
+
|
|
5
12
|
## 0.8.107 — 2026-06-19
|
|
6
13
|
- fix: Cyrillic / non-ASCII in Windows hook payloads is no longer corrupted (it was being dropped as malformed JSON). The windowless launcher now reads the payload as raw bytes straight off the process's stdin and never references PowerShell's `$input` — merely touching `$input` both pre-drains stdin and decodes it through the console's OEM codepage, mangling the UTF-8 BOM and every Cyrillic byte before forwarding. Byte-exact passthrough now; the 0.8.105 read still went through `$input` first. Cursor and Claude Code.
|
|
7
14
|
- fix: the Windows hidden-sender launch now runs detached, so it isn't killed together with the hook's process tree before it finishes starting the sender — closing a rare window where an event could be dropped. The launcher is GUI-subsystem, so detached still adds no console window (no flash).
|
package/bin/ai-lens.js
CHANGED
|
@@ -59,9 +59,10 @@ switch (command) {
|
|
|
59
59
|
console.log(' --server URL Server URL (default: saved or http://localhost:3000)');
|
|
60
60
|
console.log(' --yes, -y Non-interactive: accept all defaults, no prompts');
|
|
61
61
|
console.log(' --projects LIST Comma-separated project paths to track');
|
|
62
|
-
console.log(' --no-hooks Skip writing hooks
|
|
62
|
+
console.log(' --no-hooks Skip writing hooks (config + auth + MCP only)');
|
|
63
63
|
console.log(' --no-mcp Skip MCP server registration');
|
|
64
|
-
console.log(' --mcp-
|
|
64
|
+
console.log(' --mcp-only Register only the MCP server, mirroring your install scope (skip hooks/auth/import)');
|
|
65
|
+
console.log(' --mcp-scope S MCP scope: user, local, or project (default: auto-detected from install scope, else user)');
|
|
65
66
|
console.log(' --project-hooks Write Cursor/Claude hooks to project .cursor/ and .claude/ (not ~/.cursor)');
|
|
66
67
|
console.log(' --use-repo-path Run capture.js from this package; skip copy to ~/.ai-lens/client/');
|
|
67
68
|
console.log(' remove Remove AI Lens hooks and client files');
|
package/cli/hooks.js
CHANGED
|
@@ -1190,6 +1190,50 @@ export function detectInstalledTools(ctx = null) {
|
|
|
1190
1190
|
return tools.filter(t => existsSync(t.dirPath));
|
|
1191
1191
|
}
|
|
1192
1192
|
|
|
1193
|
+
/**
|
|
1194
|
+
* Detect whether AI Lens is installed at the PROJECT (local) or GLOBAL (user)
|
|
1195
|
+
* level, by looking at where its hooks actually live. Used to mirror the install
|
|
1196
|
+
* scope when registering the MCP server (`init --mcp-only`).
|
|
1197
|
+
*
|
|
1198
|
+
* "present" = a tool config whose hooks include an AI Lens entry, i.e.
|
|
1199
|
+
* analyzeToolHooks(...).status is 'current' or 'outdated' (NOT 'fresh'/'absent').
|
|
1200
|
+
*
|
|
1201
|
+
* Project (local) takes priority over global when both are present.
|
|
1202
|
+
*
|
|
1203
|
+
* @param {string} projectRoot - Absolute path to inspect (e.g. process.cwd()).
|
|
1204
|
+
* @returns {'project' | 'user' | null}
|
|
1205
|
+
*/
|
|
1206
|
+
export function detectAiLensInstallScope(projectRoot) {
|
|
1207
|
+
// noBackup: this is read-only scope detection — never rename/back up a malformed
|
|
1208
|
+
// file just to sniff its status (the project configs are sharedConfig:false, which
|
|
1209
|
+
// would otherwise trigger a .bak rename of the user's settings.local.json).
|
|
1210
|
+
const isPresent = (tool) => {
|
|
1211
|
+
if (!tool) return false;
|
|
1212
|
+
const status = analyzeToolHooks(tool, { noBackup: true }).status;
|
|
1213
|
+
return status === 'current' || status === 'outdated';
|
|
1214
|
+
};
|
|
1215
|
+
|
|
1216
|
+
// Project-level configs. Claude project hooks land in settings.local.json
|
|
1217
|
+
// (per-machine, gitignored); the committed `--use-repo-path` form lands in
|
|
1218
|
+
// settings.json — check BOTH so neither install style is missed.
|
|
1219
|
+
const claudeProject = getClaudeCodeToolConfig(projectRoot);
|
|
1220
|
+
const claudeProjectLocal = claudeProject
|
|
1221
|
+
? { ...claudeProject, configPath: claudeLocalSettingsPath(claudeProject) }
|
|
1222
|
+
: null;
|
|
1223
|
+
const projectTools = [
|
|
1224
|
+
claudeProject,
|
|
1225
|
+
claudeProjectLocal,
|
|
1226
|
+
getCursorToolConfig(projectRoot),
|
|
1227
|
+
getCodexToolConfig(projectRoot),
|
|
1228
|
+
];
|
|
1229
|
+
if (projectTools.some(isPresent)) return 'project';
|
|
1230
|
+
|
|
1231
|
+
// Global (user-level) configs — ~/.claude, ~/.cursor, ~/.codex.
|
|
1232
|
+
if (detectInstalledTools().some(isPresent)) return 'user';
|
|
1233
|
+
|
|
1234
|
+
return null;
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1193
1237
|
// ---------------------------------------------------------------------------
|
|
1194
1238
|
// Analysis
|
|
1195
1239
|
// ---------------------------------------------------------------------------
|
|
@@ -1215,8 +1259,10 @@ export function analyzeToolHooks(tool, opts = {}) {
|
|
|
1215
1259
|
try {
|
|
1216
1260
|
config = JSON.parse(raw);
|
|
1217
1261
|
} catch (err) {
|
|
1218
|
-
// For shared config files (settings.json), don't backup/rename — other tools depend on it
|
|
1219
|
-
|
|
1262
|
+
// For shared config files (settings.json), don't backup/rename — other tools depend on it.
|
|
1263
|
+
// opts.noBackup lets read-only callers (e.g. install-scope detection) inspect status
|
|
1264
|
+
// without mutating the user's file.
|
|
1265
|
+
if (tool.sharedConfig || opts.noBackup) {
|
|
1220
1266
|
return { status: 'malformed', error: err.message, disableAllHooks: false };
|
|
1221
1267
|
}
|
|
1222
1268
|
const bakPath = tool.configPath + '.bak';
|
|
@@ -1807,13 +1853,20 @@ function readJsonSafe(path) {
|
|
|
1807
1853
|
|
|
1808
1854
|
/**
|
|
1809
1855
|
* Add or update ai-lens MCP server in Cursor's mcp.json.
|
|
1856
|
+
* @param {string} mcpUrl
|
|
1857
|
+
* @param {object} [opts]
|
|
1858
|
+
* @param {string} [opts.projectRoot] - When set, write the project-level
|
|
1859
|
+
* <projectRoot>/.cursor/mcp.json instead of the global ~/.cursor/mcp.json.
|
|
1810
1860
|
*/
|
|
1811
|
-
export function addCursorMcp(mcpUrl) {
|
|
1812
|
-
const
|
|
1861
|
+
export function addCursorMcp(mcpUrl, { projectRoot } = {}) {
|
|
1862
|
+
const target = projectRoot
|
|
1863
|
+
? join(projectRoot, '.cursor', 'mcp.json')
|
|
1864
|
+
: CURSOR_MCP_GLOBAL;
|
|
1865
|
+
const config = readJsonSafe(target) || { mcpServers: {} };
|
|
1813
1866
|
if (!config.mcpServers) config.mcpServers = {};
|
|
1814
1867
|
config.mcpServers['ai-lens'] = cursorMcpEntry(mcpUrl);
|
|
1815
|
-
mkdirSync(dirname(
|
|
1816
|
-
writeFileSync(
|
|
1868
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
1869
|
+
writeFileSync(target, JSON.stringify(config, null, 2) + '\n');
|
|
1817
1870
|
}
|
|
1818
1871
|
|
|
1819
1872
|
/**
|
package/cli/init.js
CHANGED
|
@@ -20,9 +20,9 @@ import {
|
|
|
20
20
|
getClaudeCodeHookDefsWithPath, getClaudeCodeHookDefsWithProjectDir, getCursorHookDefsWithPath, getCodexHookDefsWithPath,
|
|
21
21
|
cleanupLegacyHooks, cleanupOppositeScope, cleanupEmptyMcpJson, addCursorMcp, removeCursorMcp,
|
|
22
22
|
checkHooksDisabled, enableHooks,
|
|
23
|
-
findStableNodePath,
|
|
23
|
+
findStableNodePath, detectAiLensInstallScope,
|
|
24
24
|
} from './hooks.js';
|
|
25
|
-
import {
|
|
25
|
+
import { scanNestedProjects, summarizeNestedProjects } from './scan.js';
|
|
26
26
|
|
|
27
27
|
function ask(question) {
|
|
28
28
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
@@ -35,6 +35,37 @@ function ask(question) {
|
|
|
35
35
|
});
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
// Default `projects` scope for `init --yes` on a FRESH config: the git root of
|
|
39
|
+
// cwd (the dev's work tree — the workspace when init is run there). Returns null
|
|
40
|
+
// when cwd isn't inside a git repo (e.g. bot containers under /app) → capture-all,
|
|
41
|
+
// which stays safe because the 90-day history import is separately gated to
|
|
42
|
+
// opt-in (importMode).
|
|
43
|
+
//
|
|
44
|
+
// Uses the INNERMOST git root (`git rev-parse --show-toplevel`), NOT an outermost
|
|
45
|
+
// walk-up: a `~/.git` dotfiles repo must never escalate the scope to $HOME (that
|
|
46
|
+
// would re-introduce capture-all + a personal-history import). Without this,
|
|
47
|
+
// `init --yes` left projects=null (= capture ALL).
|
|
48
|
+
export function defaultProjectsScope(cwd = process.cwd()) {
|
|
49
|
+
try {
|
|
50
|
+
const root = execSync('git rev-parse --show-toplevel', {
|
|
51
|
+
cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
|
|
52
|
+
}).trim();
|
|
53
|
+
return root || null;
|
|
54
|
+
} catch {
|
|
55
|
+
return null; // not a git repo (or git unavailable) → keep capture-all
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// History-import decision for `init`. Opt-in by design: `--import` runs it,
|
|
60
|
+
// interactive prompts, and `--yes` SKIPS (it used to auto-run, which silently
|
|
61
|
+
// imported the full 90-day ~/.claude history including personal projects).
|
|
62
|
+
// `--no-import` is handled earlier (skips outright before the preview).
|
|
63
|
+
export function importMode(flags = {}) {
|
|
64
|
+
if (flags.importHistory) return 'run'; // explicit opt-in (highest priority)
|
|
65
|
+
if (flags.yes) return 'skip'; // non-interactive: never auto-import
|
|
66
|
+
return 'prompt'; // interactive: ask
|
|
67
|
+
}
|
|
68
|
+
|
|
38
69
|
function getJson(url) {
|
|
39
70
|
return new Promise((resolve, reject) => {
|
|
40
71
|
const parsed = new URL(url);
|
|
@@ -193,6 +224,14 @@ function makeNestedClaudeTool(projectDir, capturePathInHooks, ctx = null) {
|
|
|
193
224
|
return tool;
|
|
194
225
|
}
|
|
195
226
|
|
|
227
|
+
function makeNestedCodexTool(projectDir, capturePathInHooks, ctx = null) {
|
|
228
|
+
const tool = getCodexToolConfig(projectDir, `Codex (${projectDir})`, ctx);
|
|
229
|
+
if (capturePathInHooks) {
|
|
230
|
+
tool.hookDefs = getCodexHookDefsWithPath(capturePathInHooks, ctx);
|
|
231
|
+
}
|
|
232
|
+
return tool;
|
|
233
|
+
}
|
|
234
|
+
|
|
196
235
|
async function deviceCodeAuth(serverUrl) {
|
|
197
236
|
// 1. Fetch Auth0 config from server
|
|
198
237
|
let config;
|
|
@@ -353,6 +392,9 @@ function getInitArgs() {
|
|
|
353
392
|
case '--no-mcp':
|
|
354
393
|
flags.noMcp = true;
|
|
355
394
|
break;
|
|
395
|
+
case '--mcp-only':
|
|
396
|
+
flags.mcpOnly = true;
|
|
397
|
+
break;
|
|
356
398
|
case '--no-hooks':
|
|
357
399
|
flags.noHooks = true;
|
|
358
400
|
break;
|
|
@@ -379,7 +421,7 @@ function getInitArgs() {
|
|
|
379
421
|
const a = args[i];
|
|
380
422
|
if (a.startsWith('-')) {
|
|
381
423
|
process.stderr.write(`Warning: unknown flag "${a}" — did you mean --${a.replace(/^-+/, '')}?\n`);
|
|
382
|
-
} else if (['server', 'projects', 'yes', 'no-mcp', 'no-hooks', 'project-hooks', 'use-repo-path', 'install-launcher', 'mcp-scope'].includes(a)) {
|
|
424
|
+
} else if (['server', 'projects', 'yes', 'no-mcp', 'mcp-only', 'no-hooks', 'project-hooks', 'use-repo-path', 'install-launcher', 'mcp-scope'].includes(a)) {
|
|
383
425
|
process.stderr.write(`Warning: unexpected argument "${a}" — did you mean --${a}?\n`);
|
|
384
426
|
}
|
|
385
427
|
}
|
|
@@ -389,6 +431,138 @@ function getInitArgs() {
|
|
|
389
431
|
return flags;
|
|
390
432
|
}
|
|
391
433
|
|
|
434
|
+
/**
|
|
435
|
+
* Resolve the server URL from --server flag / saved config / interactive prompt.
|
|
436
|
+
* Normalizes scheme + trailing slash and validates. Shared by the full init flow
|
|
437
|
+
* and the --mcp-only path.
|
|
438
|
+
*/
|
|
439
|
+
async function resolveServerUrl(flags, auto, currentConfig) {
|
|
440
|
+
const currentServer = currentConfig.serverUrl || 'http://localhost:3000';
|
|
441
|
+
let serverUrl;
|
|
442
|
+
if (flags.server) {
|
|
443
|
+
serverUrl = flags.server.replace(/\/+$/, '');
|
|
444
|
+
} else if (auto) {
|
|
445
|
+
serverUrl = currentServer;
|
|
446
|
+
} else {
|
|
447
|
+
const serverInput = await ask(`Server URL (Enter = ${currentServer}): `);
|
|
448
|
+
serverUrl = (serverInput || currentServer).replace(/\/+$/, '');
|
|
449
|
+
}
|
|
450
|
+
if (!/^https?:\/\//i.test(serverUrl)) serverUrl = `http://${serverUrl}`;
|
|
451
|
+
try { new URL(serverUrl); } catch { error(`Invalid server URL: ${serverUrl}`); process.exit(1); }
|
|
452
|
+
return serverUrl;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Register the AI Lens MCP server in Claude Code and/or Cursor (HTTP transport —
|
|
457
|
+
* auth via OAuth in browser, no token needed). Shared by the full init flow and
|
|
458
|
+
* the --mcp-only path.
|
|
459
|
+
*
|
|
460
|
+
* @param {string} serverUrl
|
|
461
|
+
* @param {object} opts
|
|
462
|
+
* @param {boolean} opts.auto - non-interactive (--yes)
|
|
463
|
+
* @param {string} [opts.mcpScope] - explicit --mcp-scope (user|local|project)
|
|
464
|
+
* @param {string} [opts.forcedScope] - pre-resolved Claude scope; skips the prompt
|
|
465
|
+
* @param {string} [opts.projectRoot] - project dir to use when the scope is local/project
|
|
466
|
+
*/
|
|
467
|
+
async function setupMcpServers(serverUrl, { auto, mcpScope, forcedScope = null, projectRoot = null }) {
|
|
468
|
+
const mcpUrl = `${serverUrl}/mcp`;
|
|
469
|
+
|
|
470
|
+
const claudeDir = join(homedir(), '.claude');
|
|
471
|
+
const hasClaudeDir = existsSync(claudeDir);
|
|
472
|
+
let hasClaudeCli = false;
|
|
473
|
+
try { execSync(process.platform === 'win32' ? 'where claude' : 'which claude', { stdio: 'ignore' }); hasClaudeCli = true; } catch {}
|
|
474
|
+
|
|
475
|
+
const cursorDir = join(homedir(), '.cursor');
|
|
476
|
+
const hasCursorDir = existsSync(cursorDir);
|
|
477
|
+
|
|
478
|
+
// A local/project scope targets the project .cursor/mcp.json; a user scope (or no
|
|
479
|
+
// project context) targets the global ~/.cursor/mcp.json. Owned here so the full
|
|
480
|
+
// init flow and --mcp-only map scope → Cursor target the same way.
|
|
481
|
+
const cursorRootFor = (scope) => ((scope === 'local' || scope === 'project') ? projectRoot : null);
|
|
482
|
+
|
|
483
|
+
// Resolve the Claude scope when it's known without prompting. null ⇒ ask
|
|
484
|
+
// interactively in the Claude block below, and the Cursor target then follows it.
|
|
485
|
+
let resolvedScope = forcedScope
|
|
486
|
+
|| (mcpScope && ['local', 'project', 'user'].includes(mcpScope) ? mcpScope : null)
|
|
487
|
+
|| (auto ? 'user' : null);
|
|
488
|
+
|
|
489
|
+
if (!hasClaudeDir && !hasCursorDir && !cursorRootFor(resolvedScope)) {
|
|
490
|
+
warn(' No MCP-capable tool found (~/.claude or ~/.cursor) — nothing to register.');
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
if (hasClaudeDir && !hasClaudeCli) {
|
|
494
|
+
warn(' Claude Code detected but the `claude` CLI is not on PATH — skipping Claude MCP registration.');
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Claude Code MCP
|
|
498
|
+
if (hasClaudeDir && hasClaudeCli) {
|
|
499
|
+
heading('MCP Server — Claude Code');
|
|
500
|
+
let doSetup;
|
|
501
|
+
if (auto) {
|
|
502
|
+
doSetup = true;
|
|
503
|
+
} else {
|
|
504
|
+
const mcpAnswer = await ask('Set up AI Lens MCP server in Claude Code? [Y/n] ');
|
|
505
|
+
doSetup = !mcpAnswer || mcpAnswer.toLowerCase() === 'y';
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
if (doSetup) {
|
|
509
|
+
if (!resolvedScope) {
|
|
510
|
+
const scopeInput = await ask(' Scope — user, local, or project? (Enter = user): ');
|
|
511
|
+
resolvedScope = ['local', 'project', 'user'].includes(scopeInput) ? scopeInput : 'user';
|
|
512
|
+
}
|
|
513
|
+
const scope = resolvedScope;
|
|
514
|
+
try {
|
|
515
|
+
try { execSync('claude mcp remove ai-lens -s local', { stdio: 'ignore', shell: true }); } catch {}
|
|
516
|
+
try { execSync('claude mcp remove ai-lens -s project', { stdio: 'ignore', shell: true }); } catch {}
|
|
517
|
+
try { execSync('claude mcp remove ai-lens -s user', { stdio: 'ignore', shell: true }); } catch {}
|
|
518
|
+
cleanupEmptyMcpJson();
|
|
519
|
+
const escapedMcpUrl = process.platform === 'win32'
|
|
520
|
+
? `"${mcpUrl.replace(/"/g, '""')}"`
|
|
521
|
+
: `'${mcpUrl.replace(/'/g, "'\\''")}'`;
|
|
522
|
+
execSync(
|
|
523
|
+
`claude mcp add --transport http ai-lens -s ${scope} ${escapedMcpUrl}`,
|
|
524
|
+
{ stdio: 'inherit', shell: true },
|
|
525
|
+
);
|
|
526
|
+
success(` MCP server registered in Claude Code (${scope})`);
|
|
527
|
+
} catch (err) {
|
|
528
|
+
error(` Failed to register MCP server: ${err.message}`);
|
|
529
|
+
}
|
|
530
|
+
} else {
|
|
531
|
+
info(' Skipped');
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// Cursor MCP. Mirror the resolved scope: project file for local/project, else global.
|
|
536
|
+
// Gate on a project target too, so a project-scoped install still registers even when
|
|
537
|
+
// the global ~/.cursor doesn't exist (fresh machine / CI / Cursor-only-in-project).
|
|
538
|
+
const cursorProjectRoot = cursorRootFor(resolvedScope);
|
|
539
|
+
if (hasCursorDir || cursorProjectRoot) {
|
|
540
|
+
heading('MCP Server — Cursor');
|
|
541
|
+
let doSetup;
|
|
542
|
+
if (auto) {
|
|
543
|
+
doSetup = true;
|
|
544
|
+
} else {
|
|
545
|
+
const cursorMcpAnswer = await ask('Set up AI Lens MCP server in Cursor? [Y/n] ');
|
|
546
|
+
doSetup = !cursorMcpAnswer || cursorMcpAnswer.toLowerCase() === 'y';
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
if (doSetup) {
|
|
550
|
+
try {
|
|
551
|
+
removeCursorMcp();
|
|
552
|
+
addCursorMcp(mcpUrl, { projectRoot: cursorProjectRoot });
|
|
553
|
+
const where = cursorProjectRoot
|
|
554
|
+
? join(cursorProjectRoot, '.cursor', 'mcp.json')
|
|
555
|
+
: '~/.cursor/mcp.json';
|
|
556
|
+
success(` MCP server registered in Cursor (${where})`);
|
|
557
|
+
} catch (err) {
|
|
558
|
+
error(` Failed to register MCP server: ${err.message}`);
|
|
559
|
+
}
|
|
560
|
+
} else {
|
|
561
|
+
info(' Skipped');
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
392
566
|
export default async function init() {
|
|
393
567
|
const flags = getInitArgs();
|
|
394
568
|
const auto = flags.yes || false;
|
|
@@ -400,6 +574,50 @@ export default async function init() {
|
|
|
400
574
|
heading(`AI Lens — Init v${version} (${commit})`);
|
|
401
575
|
detail(`capture.js: ${CAPTURE_PATH}`);
|
|
402
576
|
|
|
577
|
+
// --mcp-only: register ONLY the MCP server, mirroring the existing AI Lens
|
|
578
|
+
// install scope (local/project vs global/user). Skips hooks, auth, and import.
|
|
579
|
+
if (flags.mcpOnly) {
|
|
580
|
+
if (flags.noMcp) {
|
|
581
|
+
error('--mcp-only cannot be combined with --no-mcp.');
|
|
582
|
+
process.exit(1);
|
|
583
|
+
}
|
|
584
|
+
heading('Configuration');
|
|
585
|
+
const currentConfig = readLensConfig();
|
|
586
|
+
const serverUrl = await resolveServerUrl(flags, auto, currentConfig);
|
|
587
|
+
info(` Server: ${serverUrl}`);
|
|
588
|
+
|
|
589
|
+
// Soft health check — don't block registration (the server may be briefly down),
|
|
590
|
+
// but warn so a typo'd URL doesn't silently register a dead MCP (verification is skipped).
|
|
591
|
+
try {
|
|
592
|
+
const health = await getJson(`${serverUrl}/api/health`);
|
|
593
|
+
if (health.status !== 'ok') warn(` Server responded with unexpected status: ${JSON.stringify(health)}`);
|
|
594
|
+
} catch (err) {
|
|
595
|
+
warn(` Server unreachable (${err.message}) — registering MCP anyway; double-check the URL.`);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// Persist the server URL so it sticks for later runs.
|
|
599
|
+
saveLensConfig({ ...currentConfig, serverUrl });
|
|
600
|
+
|
|
601
|
+
// Scope precedence: explicit --mcp-scope > --project-hooks > detected footprint > user.
|
|
602
|
+
const cwd = resolve(process.cwd());
|
|
603
|
+
let forcedScope;
|
|
604
|
+
if (flags.mcpScope && ['user', 'local', 'project'].includes(flags.mcpScope)) {
|
|
605
|
+
forcedScope = flags.mcpScope;
|
|
606
|
+
} else if (flags.projectHooks) {
|
|
607
|
+
forcedScope = 'local';
|
|
608
|
+
} else {
|
|
609
|
+
const detected = detectAiLensInstallScope(cwd); // 'project' | 'user' | null
|
|
610
|
+
forcedScope = detected === 'project' ? 'local' : 'user';
|
|
611
|
+
}
|
|
612
|
+
info(` Install scope: ${forcedScope === 'user' ? 'global (user)' : 'local (project)'}`);
|
|
613
|
+
|
|
614
|
+
await setupMcpServers(serverUrl, { auto, forcedScope, projectRoot: cwd });
|
|
615
|
+
|
|
616
|
+
heading('Done');
|
|
617
|
+
info(' MCP-only setup complete — hooks, auth, and import were skipped.');
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
|
|
403
621
|
// Resolve a stable node binary path up-front. The same resolution is baked into
|
|
404
622
|
// the per-machine launcher (run.sh / run.cmd) and into hook commands. We only
|
|
405
623
|
// need it when hooks will actually be written; --no-hooks skips it so users
|
|
@@ -546,20 +764,7 @@ export default async function init() {
|
|
|
546
764
|
const currentConfig = readLensConfig();
|
|
547
765
|
|
|
548
766
|
// Server URL
|
|
549
|
-
const
|
|
550
|
-
let serverUrl;
|
|
551
|
-
if (flags.server) {
|
|
552
|
-
serverUrl = flags.server.replace(/\/+$/, '');
|
|
553
|
-
} else if (auto) {
|
|
554
|
-
serverUrl = currentServer;
|
|
555
|
-
} else {
|
|
556
|
-
const serverInput = await ask(
|
|
557
|
-
`Server URL (Enter = ${currentServer}): `,
|
|
558
|
-
);
|
|
559
|
-
serverUrl = (serverInput || currentServer).replace(/\/+$/, '');
|
|
560
|
-
}
|
|
561
|
-
if (!/^https?:\/\//i.test(serverUrl)) serverUrl = `http://${serverUrl}`;
|
|
562
|
-
try { new URL(serverUrl); } catch { error(`Invalid server URL: ${serverUrl}`); process.exit(1); }
|
|
767
|
+
const serverUrl = await resolveServerUrl(flags, auto, currentConfig);
|
|
563
768
|
info(` Server: ${serverUrl}`);
|
|
564
769
|
|
|
565
770
|
// Project filter
|
|
@@ -569,7 +774,11 @@ export default async function init() {
|
|
|
569
774
|
if (flags.projects) {
|
|
570
775
|
projects = flags.projects;
|
|
571
776
|
} else if (auto) {
|
|
572
|
-
|
|
777
|
+
// Fresh config under --yes: default to the git root of cwd (the work tree),
|
|
778
|
+
// not null. null = capture ALL projects. Stays null only when cwd isn't a
|
|
779
|
+
// git repo (bots/containers) — safe because the import is separately gated.
|
|
780
|
+
// `--projects all` is the explicit opt-in for capture-everything.
|
|
781
|
+
projects = currentProjects || projectHooksDefault || defaultProjectsScope();
|
|
573
782
|
} else {
|
|
574
783
|
const projectsDefault = currentProjects || projectHooksDefault || 'all';
|
|
575
784
|
const projectsInput = await ask(
|
|
@@ -756,34 +965,50 @@ export default async function init() {
|
|
|
756
965
|
|
|
757
966
|
let nestedScan = [];
|
|
758
967
|
let nestedPending = [];
|
|
968
|
+
let nestedCodexTrust = [];
|
|
759
969
|
if (flags.projectHooks) {
|
|
760
970
|
const trackedRoots = getTrackedRoots(newConfig.projects, resolve(process.cwd()));
|
|
761
|
-
nestedScan =
|
|
971
|
+
nestedScan = scanNestedProjects(trackedRoots);
|
|
762
972
|
|
|
763
973
|
if (nestedScan.length > 0) {
|
|
764
|
-
heading('Nested
|
|
974
|
+
heading('Nested projects');
|
|
765
975
|
for (const result of nestedScan) {
|
|
766
976
|
const installNote = result.installTarget ? ` -> install in ${result.installTarget}` : '';
|
|
767
|
-
info(` ${result.relativePath}: ${result.status}${installNote}`);
|
|
977
|
+
info(` ${result.relativePath} (${result.tool}): ${result.status}${installNote}`);
|
|
768
978
|
}
|
|
769
979
|
const summary = summarizeNestedProjects(nestedScan);
|
|
770
|
-
detail(`Found ${summary.total} nested
|
|
980
|
+
detail(`Found ${summary.total} nested hook target(s), ${summary.unhooked} need attention.`);
|
|
771
981
|
blank();
|
|
772
982
|
}
|
|
773
983
|
|
|
774
984
|
const nestedActionable = nestedScan.filter(result => result.installTarget);
|
|
985
|
+
nestedCodexTrust = nestedScan
|
|
986
|
+
.filter(result => result.tool === 'codex' && !result.installTarget)
|
|
987
|
+
.map(result => ({ tool: makeNestedCodexTool(result.projectDir, null, ctx) }));
|
|
775
988
|
let shouldInstallNested = auto && nestedActionable.length > 0;
|
|
776
989
|
if (!auto && nestedActionable.length > 0) {
|
|
777
|
-
const answer = await ask(`Install hooks in ${nestedActionable.length} nested
|
|
990
|
+
const answer = await ask(`Install hooks in ${nestedActionable.length} nested hook target(s)? [Y/n] `);
|
|
778
991
|
shouldInstallNested = !answer || ['y', 'yes'].includes(answer.toLowerCase());
|
|
779
992
|
}
|
|
780
993
|
|
|
781
994
|
if (shouldInstallNested) {
|
|
782
995
|
nestedPending = nestedActionable.map(result => {
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
996
|
+
let tool;
|
|
997
|
+
if (result.tool === 'codex') {
|
|
998
|
+
const capturePathInHooks = flags.useRepoPath
|
|
999
|
+
? (() => {
|
|
1000
|
+
const abs = resolve(REPO_CAPTURE_PATH);
|
|
1001
|
+
const userHome = homedir();
|
|
1002
|
+
return abs.startsWith(userHome) ? `~${abs.slice(userHome.length).replace(/\\/g, '/')}` : abs.replace(/\\/g, '/');
|
|
1003
|
+
})()
|
|
1004
|
+
: null;
|
|
1005
|
+
tool = makeNestedCodexTool(result.projectDir, capturePathInHooks, ctx);
|
|
1006
|
+
} else {
|
|
1007
|
+
const capturePathInHooks = flags.useRepoPath
|
|
1008
|
+
? relative(result.projectDir, resolve(REPO_CAPTURE_PATH)).replace(/\\/g, '/')
|
|
1009
|
+
: null;
|
|
1010
|
+
tool = makeNestedClaudeTool(result.projectDir, capturePathInHooks, ctx);
|
|
1011
|
+
}
|
|
787
1012
|
tool.configPath = join(tool.dirPath, result.installTarget);
|
|
788
1013
|
return {
|
|
789
1014
|
tool,
|
|
@@ -822,7 +1047,7 @@ export default async function init() {
|
|
|
822
1047
|
// Idempotent: re-run hook trust enrollment even when no hooks needed
|
|
823
1048
|
// rewriting. Without this, a previously-installed-but-untrusted Codex
|
|
824
1049
|
// hook stays inert until the next time hooks themselves change.
|
|
825
|
-
for (const { tool } of analyses) {
|
|
1050
|
+
for (const { tool } of [...analyses, ...nestedCodexTrust]) {
|
|
826
1051
|
if (tool.name !== 'Codex' && !tool.name.startsWith('Codex')) continue;
|
|
827
1052
|
try {
|
|
828
1053
|
const r = enableCodexHookTrust(tool.configPath);
|
|
@@ -861,8 +1086,10 @@ export default async function init() {
|
|
|
861
1086
|
|
|
862
1087
|
for (const { tool, analysis } of [...pending, ...nestedPending]) {
|
|
863
1088
|
try {
|
|
864
|
-
// Backup malformed
|
|
865
|
-
|
|
1089
|
+
// Backup malformed configs before overwriting. analyzeToolHooks already
|
|
1090
|
+
// renames non-shared malformed files; nested scan is read-only, so it
|
|
1091
|
+
// reaches this path and needs the backup here.
|
|
1092
|
+
if (analysis.status === 'malformed' && existsSync(tool.configPath)) {
|
|
866
1093
|
try { copyFileSync(tool.configPath, tool.configPath + '.bak'); } catch { /* file may be gone */ }
|
|
867
1094
|
}
|
|
868
1095
|
const existingConfig = analysis.config || null;
|
|
@@ -882,7 +1109,7 @@ export default async function init() {
|
|
|
882
1109
|
// key, no `file:` prefix) — the same state the TUI "Trust all" flow writes,
|
|
883
1110
|
// but driven by AI Lens at install time. With it, `codex exec` fires hooks
|
|
884
1111
|
// with no interactive step.
|
|
885
|
-
for (const { tool } of [...pending, ...nestedPending]) {
|
|
1112
|
+
for (const { tool } of [...pending, ...nestedPending, ...nestedCodexTrust]) {
|
|
886
1113
|
if (tool.name !== 'Codex' && !tool.name.startsWith('Codex')) continue;
|
|
887
1114
|
try {
|
|
888
1115
|
const r = enableCodexHookTrust(tool.configPath);
|
|
@@ -1074,81 +1301,17 @@ export default async function init() {
|
|
|
1074
1301
|
} catch {}
|
|
1075
1302
|
}
|
|
1076
1303
|
|
|
1077
|
-
// MCP setup (HTTP transport — auth via OAuth in browser, no token needed)
|
|
1304
|
+
// MCP setup (HTTP transport — auth via OAuth in browser, no token needed).
|
|
1305
|
+
// --project-hooks installs hooks at project scope, so mirror that for the MCP:
|
|
1306
|
+
// default the scope to local and target the project .cursor/mcp.json (an explicit
|
|
1307
|
+
// --mcp-scope still wins; setupMcpServers maps scope → Cursor target).
|
|
1078
1308
|
if (!flags.noMcp) {
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
try { execSync(process.platform === 'win32' ? 'where claude' : 'which claude', { stdio: 'ignore' }); hasClaudeCli = true; } catch {}
|
|
1086
|
-
|
|
1087
|
-
if (hasClaudeDir && hasClaudeCli) {
|
|
1088
|
-
heading('MCP Server — Claude Code');
|
|
1089
|
-
let doSetup;
|
|
1090
|
-
if (auto) {
|
|
1091
|
-
doSetup = true;
|
|
1092
|
-
} else {
|
|
1093
|
-
const mcpAnswer = await ask('Set up AI Lens MCP server in Claude Code? [Y/n] ');
|
|
1094
|
-
doSetup = !mcpAnswer || mcpAnswer.toLowerCase() === 'y';
|
|
1095
|
-
}
|
|
1096
|
-
|
|
1097
|
-
if (doSetup) {
|
|
1098
|
-
let scope;
|
|
1099
|
-
if (flags.mcpScope && ['local', 'project', 'user'].includes(flags.mcpScope)) {
|
|
1100
|
-
scope = flags.mcpScope;
|
|
1101
|
-
} else if (auto) {
|
|
1102
|
-
scope = 'user';
|
|
1103
|
-
} else {
|
|
1104
|
-
const scopeInput = await ask(' Scope — user, local, or project? (Enter = user): ');
|
|
1105
|
-
scope = ['local', 'project', 'user'].includes(scopeInput) ? scopeInput : 'user';
|
|
1106
|
-
}
|
|
1107
|
-
try {
|
|
1108
|
-
try { execSync('claude mcp remove ai-lens -s local', { stdio: 'ignore', shell: true }); } catch {}
|
|
1109
|
-
try { execSync('claude mcp remove ai-lens -s project', { stdio: 'ignore', shell: true }); } catch {}
|
|
1110
|
-
try { execSync('claude mcp remove ai-lens -s user', { stdio: 'ignore', shell: true }); } catch {}
|
|
1111
|
-
cleanupEmptyMcpJson();
|
|
1112
|
-
const escapedMcpUrl = process.platform === 'win32'
|
|
1113
|
-
? `"${mcpUrl.replace(/"/g, '""')}"`
|
|
1114
|
-
: `'${mcpUrl.replace(/'/g, "'\\''")}'`;
|
|
1115
|
-
execSync(
|
|
1116
|
-
`claude mcp add --transport http ai-lens -s ${scope} ${escapedMcpUrl}`,
|
|
1117
|
-
{ stdio: 'inherit', shell: true },
|
|
1118
|
-
);
|
|
1119
|
-
success(` MCP server registered in Claude Code (${scope})`);
|
|
1120
|
-
} catch (err) {
|
|
1121
|
-
error(` Failed to register MCP server: ${err.message}`);
|
|
1122
|
-
}
|
|
1123
|
-
} else {
|
|
1124
|
-
info(' Skipped');
|
|
1125
|
-
}
|
|
1126
|
-
}
|
|
1127
|
-
|
|
1128
|
-
// Cursor MCP
|
|
1129
|
-
const cursorDir = join(homedir(), '.cursor');
|
|
1130
|
-
if (existsSync(cursorDir)) {
|
|
1131
|
-
heading('MCP Server — Cursor');
|
|
1132
|
-
let doSetup;
|
|
1133
|
-
if (auto) {
|
|
1134
|
-
doSetup = true;
|
|
1135
|
-
} else {
|
|
1136
|
-
const cursorMcpAnswer = await ask('Set up AI Lens MCP server in Cursor? [Y/n] ');
|
|
1137
|
-
doSetup = !cursorMcpAnswer || cursorMcpAnswer.toLowerCase() === 'y';
|
|
1138
|
-
}
|
|
1139
|
-
|
|
1140
|
-
if (doSetup) {
|
|
1141
|
-
try {
|
|
1142
|
-
removeCursorMcp();
|
|
1143
|
-
addCursorMcp(mcpUrl);
|
|
1144
|
-
success(' MCP server registered in Cursor (~/.cursor/mcp.json)');
|
|
1145
|
-
} catch (err) {
|
|
1146
|
-
error(` Failed to register MCP server: ${err.message}`);
|
|
1147
|
-
}
|
|
1148
|
-
} else {
|
|
1149
|
-
info(' Skipped');
|
|
1150
|
-
}
|
|
1151
|
-
}
|
|
1309
|
+
await setupMcpServers(serverUrl, {
|
|
1310
|
+
auto,
|
|
1311
|
+
mcpScope: flags.mcpScope,
|
|
1312
|
+
forcedScope: (flags.projectHooks && !flags.mcpScope) ? 'local' : null,
|
|
1313
|
+
projectRoot: flags.projectHooks ? resolve(process.cwd()) : null,
|
|
1314
|
+
});
|
|
1152
1315
|
}
|
|
1153
1316
|
|
|
1154
1317
|
// Quick verification
|
|
@@ -1278,7 +1441,8 @@ export default async function init() {
|
|
|
1278
1441
|
|
|
1279
1442
|
/**
|
|
1280
1443
|
* After a successful init, offer to import the developer's local Claude Code
|
|
1281
|
-
* history. `--no-import` skips; `--import`
|
|
1444
|
+
* history. `--no-import` skips; `--import` runs it without prompting; `--yes`
|
|
1445
|
+
* does NOT import (opt-in only — avoids silently pulling personal history);
|
|
1282
1446
|
* otherwise asks interactively. No-op if there's no `~/.claude/projects`.
|
|
1283
1447
|
*/
|
|
1284
1448
|
async function maybeOfferImportHistory(flags) {
|
|
@@ -1301,18 +1465,21 @@ async function maybeOfferImportHistory(flags) {
|
|
|
1301
1465
|
? `Found ${preview.count} Claude Code session${preview.count === 1 ? '' : 's'} from ${day(preview.earliest)} to ${day(preview.latest)} (last 90d).`
|
|
1302
1466
|
: 'Local Claude Code history found.';
|
|
1303
1467
|
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1468
|
+
info(` ${previewLine}`);
|
|
1469
|
+
// History import is opt-IN (importMode): `--import` runs, interactive asks,
|
|
1470
|
+
// `--yes` SKIPS. `--yes` used to auto-import (`|| flags.yes`), silently pulling
|
|
1471
|
+
// the full 90-day ~/.claude history (incl. personal projects) on a fresh config.
|
|
1472
|
+
const mode = importMode(flags);
|
|
1473
|
+
let run = mode === 'run';
|
|
1474
|
+
if (mode === 'prompt') {
|
|
1308
1475
|
try {
|
|
1309
|
-
const answer = (await ask(
|
|
1476
|
+
const answer = (await ask(' Import now? (Y/n) ')).toLowerCase();
|
|
1310
1477
|
run = answer === '' || answer === 'y' || answer === 'yes';
|
|
1311
1478
|
} catch { run = false; }
|
|
1312
1479
|
}
|
|
1313
1480
|
if (!run) {
|
|
1314
1481
|
blank();
|
|
1315
|
-
info(' Skipped import. Run `npx -y ai-lens import claude-code`
|
|
1482
|
+
info(' Skipped local history import. Run `npx -y ai-lens import claude-code` (or pass `--import`) to bring it in.');
|
|
1316
1483
|
return;
|
|
1317
1484
|
}
|
|
1318
1485
|
|
package/cli/scan.js
CHANGED
|
@@ -78,40 +78,68 @@ function hasAnyNonAiLensHooks(config) {
|
|
|
78
78
|
return collectHookEntries(config).some(entry => !isAiLensHook(entry));
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
function
|
|
82
|
-
|
|
83
|
-
const settingsPath = join(claudeDir, 'settings.json');
|
|
84
|
-
const settingsLocalPath = join(claudeDir, 'settings.local.json');
|
|
85
|
-
const settings = readJsonSafe(settingsPath);
|
|
86
|
-
const settingsLocal = readJsonSafe(settingsLocalPath);
|
|
87
|
-
|
|
88
|
-
if (settings === 'MALFORMED' || settingsLocal === 'MALFORMED') {
|
|
81
|
+
function classifyHookProject({ tool, configs, installTarget, existingConfig, malformedInstallTarget = null }) {
|
|
82
|
+
if (configs.some(config => config === 'MALFORMED') || existingConfig === 'MALFORMED') {
|
|
89
83
|
return {
|
|
84
|
+
tool,
|
|
90
85
|
status: 'malformed',
|
|
91
|
-
installTarget:
|
|
86
|
+
installTarget: malformedInstallTarget,
|
|
92
87
|
existingConfig: null,
|
|
93
88
|
};
|
|
94
89
|
}
|
|
95
90
|
|
|
96
|
-
if (hasAnyAiLensHook
|
|
91
|
+
if (configs.some(hasAnyAiLensHook) || hasAnyAiLensHook(existingConfig)) {
|
|
97
92
|
return {
|
|
93
|
+
tool,
|
|
98
94
|
status: 'installed',
|
|
99
95
|
installTarget: null,
|
|
100
96
|
existingConfig: null,
|
|
101
97
|
};
|
|
102
98
|
}
|
|
103
99
|
|
|
100
|
+
return {
|
|
101
|
+
tool,
|
|
102
|
+
status: configs.some(hasAnyNonAiLensHooks) || hasAnyNonAiLensHooks(existingConfig)
|
|
103
|
+
? 'has non-ai-lens hooks'
|
|
104
|
+
: 'missing',
|
|
105
|
+
installTarget,
|
|
106
|
+
existingConfig,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function classifyClaudeProject(projectDir) {
|
|
111
|
+
const claudeDir = join(projectDir, '.claude');
|
|
112
|
+
const settingsPath = join(claudeDir, 'settings.json');
|
|
113
|
+
const settingsLocalPath = join(claudeDir, 'settings.local.json');
|
|
114
|
+
const settings = readJsonSafe(settingsPath);
|
|
115
|
+
const settingsLocal = readJsonSafe(settingsLocalPath);
|
|
116
|
+
|
|
104
117
|
// AI Lens Claude hooks are per-machine / OS-specific (ADR 0003 — absolute node path,
|
|
105
118
|
// conhost.exe on Windows). They must NEVER be written into a COMMITTED settings.json
|
|
106
119
|
// (that re-introduces the cross-OS / cross-machine breakage MR !298 removed). Always
|
|
107
120
|
// install into the gitignored, per-machine settings.local.json, which Claude Code
|
|
108
121
|
// merges with settings.json at runtime — so a project's own non-ai-lens hooks in
|
|
109
122
|
// settings.json keep working alongside.
|
|
110
|
-
return {
|
|
111
|
-
|
|
123
|
+
return classifyHookProject({
|
|
124
|
+
tool: 'claude',
|
|
125
|
+
configs: [settings],
|
|
112
126
|
installTarget: 'settings.local.json',
|
|
113
127
|
existingConfig: settingsLocal,
|
|
114
|
-
};
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function classifyCodexProject(projectDir) {
|
|
132
|
+
const codexDir = join(projectDir, '.codex');
|
|
133
|
+
const hooksPath = join(codexDir, 'hooks.json');
|
|
134
|
+
const hooks = readJsonSafe(hooksPath);
|
|
135
|
+
|
|
136
|
+
return classifyHookProject({
|
|
137
|
+
tool: 'codex',
|
|
138
|
+
configs: [],
|
|
139
|
+
installTarget: 'hooks.json',
|
|
140
|
+
malformedInstallTarget: 'hooks.json',
|
|
141
|
+
existingConfig: hooks,
|
|
142
|
+
});
|
|
115
143
|
}
|
|
116
144
|
|
|
117
145
|
function scanRoot(rootPath, options, results, seen) {
|
|
@@ -133,18 +161,40 @@ function scanRoot(rootPath, options, results, seen) {
|
|
|
133
161
|
const childPath = join(currentDir, child.name);
|
|
134
162
|
if (isIgnoredByGitignore(rules, childPath)) continue;
|
|
135
163
|
|
|
136
|
-
if (child.name === '.git' || child.name === '.claude') {
|
|
164
|
+
if (child.name === '.git' || child.name === '.claude' || child.name === '.codex') {
|
|
137
165
|
const projectDir = resolve(currentDir);
|
|
138
166
|
if (projectDir !== rootPath) {
|
|
139
167
|
const depth = countDepth(rootPath, projectDir);
|
|
140
|
-
if (depth <= maxDepth
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
168
|
+
if (depth <= maxDepth) {
|
|
169
|
+
const projectKey = `${rootPath}:${projectDir}`;
|
|
170
|
+
if (!seen.has(projectKey)) {
|
|
171
|
+
seen.add(projectKey);
|
|
172
|
+
const base = {
|
|
173
|
+
projectDir,
|
|
174
|
+
relativePath: relative(rootPath, projectDir).replace(/\\/g, '/'),
|
|
175
|
+
marker: child.name,
|
|
176
|
+
};
|
|
177
|
+
// Classify by which markers ACTUALLY exist in the dir, not by which
|
|
178
|
+
// child readdir happened to yield first. readdirSync order is
|
|
179
|
+
// filesystem-dependent (unsorted), so gating Claude on
|
|
180
|
+
// `child.name !== '.codex'` dropped the Claude target whenever
|
|
181
|
+
// `.codex` was enumerated before `.git`/`.claude` in the same repo
|
|
182
|
+
// (latent on Linux; ANL-1126 review). Codex is offered for every
|
|
183
|
+
// project dir; Claude only for git/.claude repos (a .codex-only dir
|
|
184
|
+
// is not a Claude project).
|
|
185
|
+
const isClaudeProject = existsSync(join(projectDir, '.git'))
|
|
186
|
+
|| existsSync(join(projectDir, '.claude'));
|
|
187
|
+
if (isClaudeProject) {
|
|
188
|
+
results.push({
|
|
189
|
+
...base,
|
|
190
|
+
...classifyClaudeProject(projectDir),
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
results.push({
|
|
194
|
+
...base,
|
|
195
|
+
...classifyCodexProject(projectDir),
|
|
196
|
+
});
|
|
197
|
+
}
|
|
148
198
|
}
|
|
149
199
|
}
|
|
150
200
|
continue;
|
|
@@ -160,7 +210,7 @@ function scanRoot(rootPath, options, results, seen) {
|
|
|
160
210
|
visit(rootPath);
|
|
161
211
|
}
|
|
162
212
|
|
|
163
|
-
export function
|
|
213
|
+
export function scanNestedProjects(projectRoots, options = {}) {
|
|
164
214
|
const maxDepth = Number.isInteger(options.maxDepth) ? options.maxDepth : DEFAULT_MAX_DEPTH;
|
|
165
215
|
const roots = (projectRoots || []).map(path => resolve(path));
|
|
166
216
|
const results = [];
|
|
@@ -171,7 +221,15 @@ export function scanNestedClaudeProjects(projectRoots, options = {}) {
|
|
|
171
221
|
scanRoot(rootPath, { maxDepth }, results, seen);
|
|
172
222
|
}
|
|
173
223
|
|
|
174
|
-
return results.sort((a, b) =>
|
|
224
|
+
return results.sort((a, b) => (
|
|
225
|
+
a.relativePath.localeCompare(b.relativePath) || a.tool.localeCompare(b.tool)
|
|
226
|
+
));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function scanNestedClaudeProjects(projectRoots, options = {}) {
|
|
230
|
+
return scanNestedProjects(projectRoots, options)
|
|
231
|
+
.filter(result => result.tool === 'claude')
|
|
232
|
+
.map(({ tool, ...result }) => result);
|
|
175
233
|
}
|
|
176
234
|
|
|
177
235
|
export function summarizeNestedProjects(results) {
|
package/cli/status.js
CHANGED
|
@@ -11,7 +11,7 @@ import { getVersionInfo, readLensConfig, detectInstalledTools, getCursorToolConf
|
|
|
11
11
|
import { DATA_DIR, PENDING_DIR, SENDING_DIR, SESSION_PATHS_DIR, LOG_PATH, CAPTURE_LOG_PATH, LAST_STATUS_REPORT_PATH, getGitIdentity, getMonitoredProjects } from '../client/config.js';
|
|
12
12
|
import { isLockStale } from '../client/sender.js';
|
|
13
13
|
import { initLogger, info, success, warn, error, heading, blank } from './logger.js';
|
|
14
|
-
import {
|
|
14
|
+
import { scanNestedProjects, summarizeNestedProjects } from './scan.js';
|
|
15
15
|
|
|
16
16
|
const INIT_LOG_PATH = join(DATA_DIR, 'init.log');
|
|
17
17
|
|
|
@@ -510,6 +510,16 @@ function checkGitIdentity() {
|
|
|
510
510
|
return { ok: false, summary: 'not configured', detail: 'Git identity not configured (git config user.email)' };
|
|
511
511
|
}
|
|
512
512
|
|
|
513
|
+
// Server-driven update nudge ([0093]): the sender caches the server's verdict to
|
|
514
|
+
// ~/.ai-lens/messages.json. Read-only here (no network); returns the outdated
|
|
515
|
+
// message if the server says this client is behind the latest published version.
|
|
516
|
+
function readOutdatedNudge() {
|
|
517
|
+
try {
|
|
518
|
+
const cache = JSON.parse(readFileSync(join(homedir(), '.ai-lens', 'messages.json'), 'utf-8'));
|
|
519
|
+
return (cache.messages || []).find(m => m && typeof m.id === 'string' && m.id.startsWith('outdated:')) || null;
|
|
520
|
+
} catch { return null; }
|
|
521
|
+
}
|
|
522
|
+
|
|
513
523
|
export function checkClientFiles(tools = []) {
|
|
514
524
|
// In repo-path mode the hooks run capture.js straight from the repo, so the
|
|
515
525
|
// ~/.ai-lens/client/ copy (if any) is an UNUSED leftover from a prior copy-mode
|
|
@@ -529,6 +539,10 @@ export function checkClientFiles(tools = []) {
|
|
|
529
539
|
if (copyExists) {
|
|
530
540
|
detail += `\n Note: ~/.ai-lens/client/ exists but is UNUSED in repo-path mode (stale leftover from an old copy-install; safe to ignore).`;
|
|
531
541
|
}
|
|
542
|
+
// Server nudge (if behind latest published): surface as an info line, but keep
|
|
543
|
+
// ok=true — a stale repo checkout is fixed by /sync, not a false ✗ here (Y4).
|
|
544
|
+
const repoNudge = readOutdatedNudge();
|
|
545
|
+
if (repoNudge) detail += `\n ! ${repoNudge.text.split('\n').join('\n ')}`;
|
|
532
546
|
// Healthy as long as the hook-source version is readable. Don't compare against
|
|
533
547
|
// the CLI: `ai-lens status` may run from a global npx CLI while the hooks point
|
|
534
548
|
// at a checkout — that mismatch is expected, not an error. A stale repo checkout
|
|
@@ -557,9 +571,16 @@ export function checkClientFiles(tools = []) {
|
|
|
557
571
|
const clientCommit = versionJson.commit || 'unknown';
|
|
558
572
|
outdated = clientVersion !== cliVersion || clientCommit !== cliCommit;
|
|
559
573
|
versionDetail = `\n Client version: ${clientVersion} (${clientCommit})`;
|
|
574
|
+
const nudge = readOutdatedNudge();
|
|
560
575
|
if (outdated) {
|
|
561
576
|
versionDetail += `\n CLI version: ${cliVersion} (${cliCommit})`;
|
|
562
|
-
versionDetail += `\n ! Client is outdated — run: npx -y ai-lens init --yes`;
|
|
577
|
+
if (!nudge) versionDetail += `\n ! Client is outdated — run: npx -y ai-lens init --yes`;
|
|
578
|
+
}
|
|
579
|
+
// Server says behind latest published → copy-mode must re-init. Prefer the
|
|
580
|
+
// server's line (it carries the exact latest version + command).
|
|
581
|
+
if (nudge) {
|
|
582
|
+
versionDetail += `\n ! ${nudge.text.split('\n').join('\n ')}`;
|
|
583
|
+
outdated = true;
|
|
563
584
|
}
|
|
564
585
|
} catch {
|
|
565
586
|
versionDetail = `\n Client version: unknown (version.json not found — run: npx -y ai-lens init)`;
|
|
@@ -1564,7 +1585,7 @@ export default async function status({ report = false } = {}) {
|
|
|
1564
1585
|
|
|
1565
1586
|
if (hasProjectHooks) {
|
|
1566
1587
|
const nestedRoots = getMonitoredProjects() || [process.cwd()];
|
|
1567
|
-
const nestedProjects =
|
|
1588
|
+
const nestedProjects = scanNestedProjects(nestedRoots);
|
|
1568
1589
|
const nestedSummary = summarizeNestedProjects(nestedProjects);
|
|
1569
1590
|
const nestedUnhooked = nestedProjects.filter(result => result.status !== 'installed');
|
|
1570
1591
|
printLine('Nested projects', {
|
|
@@ -1572,10 +1593,10 @@ export default async function status({ report = false } = {}) {
|
|
|
1572
1593
|
summary: nestedSummary.total === 0
|
|
1573
1594
|
? 'none found'
|
|
1574
1595
|
: nestedSummary.unhooked === 0
|
|
1575
|
-
? `${nestedSummary.total} nested
|
|
1576
|
-
: `${nestedSummary.unhooked} unhooked / ${nestedSummary.total} nested
|
|
1596
|
+
? `${nestedSummary.total} nested hook target(s), all hooked`
|
|
1597
|
+
: `${nestedSummary.unhooked} unhooked / ${nestedSummary.total} nested hook target(s)`,
|
|
1577
1598
|
detail: nestedUnhooked.length > 0
|
|
1578
|
-
? `nested_unhooked_projects:\n${nestedUnhooked.map(result => `- ${result.projectDir} (${result.status})`).join('\n')}`
|
|
1599
|
+
? `nested_unhooked_projects:\n${nestedUnhooked.map(result => `- ${result.projectDir} (${result.tool}: ${result.status})`).join('\n')}`
|
|
1579
1600
|
: 'nested_unhooked_projects: none',
|
|
1580
1601
|
});
|
|
1581
1602
|
}
|
package/client/capture.js
CHANGED
|
@@ -29,6 +29,8 @@ import {
|
|
|
29
29
|
getGitIdentity,
|
|
30
30
|
getGitMetadata,
|
|
31
31
|
getMonitoredProjects,
|
|
32
|
+
MESSAGES_PATH,
|
|
33
|
+
MESSAGES_SHOWN_PATH,
|
|
32
34
|
} from './config.js';
|
|
33
35
|
import { isLockStale, isSenderBackoffActive } from './sender.js';
|
|
34
36
|
import { toNumberOrNull, buildTokenUsageRaw } from './token-usage.js';
|
|
@@ -1379,6 +1381,86 @@ export function trySpawnSender() {
|
|
|
1379
1381
|
}
|
|
1380
1382
|
|
|
1381
1383
|
|
|
1384
|
+
// =============================================================================
|
|
1385
|
+
// Server-driven client messages ([0093]) — render on SessionStart
|
|
1386
|
+
// =============================================================================
|
|
1387
|
+
|
|
1388
|
+
const MESSAGE_THROTTLE_MS = 24 * 60 * 60 * 1000; // show a given message ≤ once/day
|
|
1389
|
+
const MESSAGE_MAX_LEN = 2000;
|
|
1390
|
+
// Appended only for Cursor (additional_context → agent context) to raise the odds
|
|
1391
|
+
// the agent relays the nudge to the human. NOT added for Claude (direct banner).
|
|
1392
|
+
const CURSOR_RELAY_SUFFIX = '\n(Сообщи это пользователю в начале своего ответа.)';
|
|
1393
|
+
|
|
1394
|
+
function readJsonFileSafe(path) {
|
|
1395
|
+
try { return JSON.parse(readFileSync(path, 'utf-8')); } catch { return null; }
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
function sanitizeMessageText(s) {
|
|
1399
|
+
if (typeof s !== 'string') return '';
|
|
1400
|
+
// Strip control chars except newline/tab; cap length (defense-in-depth — the
|
|
1401
|
+
// text is server-controlled and enters the tool's context).
|
|
1402
|
+
let out = '';
|
|
1403
|
+
for (const ch of s) {
|
|
1404
|
+
const c = ch.codePointAt(0);
|
|
1405
|
+
if (c === 9 || c === 10 || (c >= 32 && c !== 127)) out += ch;
|
|
1406
|
+
if (out.length >= MESSAGE_MAX_LEN) break;
|
|
1407
|
+
}
|
|
1408
|
+
return out.slice(0, MESSAGE_MAX_LEN);
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
/** First cache message whose id hasn't been shown within the throttle window. */
|
|
1412
|
+
function pickUnthrottledMessage(messages, shownMap, nowMs) {
|
|
1413
|
+
for (const m of messages) {
|
|
1414
|
+
if (!m || !m.id || typeof m.text !== 'string') continue;
|
|
1415
|
+
const last = shownMap[m.id] ? Date.parse(shownMap[m.id]) : 0;
|
|
1416
|
+
if (!Number.isFinite(last) || nowMs - last >= MESSAGE_THROTTLE_MS) return m;
|
|
1417
|
+
}
|
|
1418
|
+
return null;
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
/** Atomically record `id` as shown now, pruning ids no longer in the cache. */
|
|
1422
|
+
function markShown(id, validIds, nowIso) {
|
|
1423
|
+
try {
|
|
1424
|
+
const map = readJsonFileSafe(MESSAGES_SHOWN_PATH) || {};
|
|
1425
|
+
const next = {};
|
|
1426
|
+
for (const k of validIds) if (map[k]) next[k] = map[k];
|
|
1427
|
+
next[id] = nowIso;
|
|
1428
|
+
const tmp = MESSAGES_SHOWN_PATH + '.tmp.' + process.pid;
|
|
1429
|
+
writeFileSync(tmp, JSON.stringify(next));
|
|
1430
|
+
renameSync(tmp, MESSAGES_SHOWN_PATH);
|
|
1431
|
+
} catch { /* best-effort */ }
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
/**
|
|
1435
|
+
* On SessionStart, render a cached server message into the tool's context via the
|
|
1436
|
+
* one stdout write capture.js ever makes. Claude → `systemMessage` (banner);
|
|
1437
|
+
* Cursor → `additional_context` (+ relay suffix). Codex/unknown → nothing.
|
|
1438
|
+
* Identity-independent (reads only the local cache), throttled per id, and fully
|
|
1439
|
+
* isolated: any failure is swallowed so it can never flip capture to exit≠0.
|
|
1440
|
+
*/
|
|
1441
|
+
export function maybeEmitSessionStartMessage(primary, { now = Date.now() } = {}) {
|
|
1442
|
+
try {
|
|
1443
|
+
if (!primary || primary.type !== 'SessionStart') return;
|
|
1444
|
+
const source = primary.source;
|
|
1445
|
+
if (source !== 'claude_code' && source !== 'cursor') return; // codex/unknown deferred
|
|
1446
|
+
const cache = readJsonFileSafe(MESSAGES_PATH);
|
|
1447
|
+
const messages = cache && Array.isArray(cache.messages) ? cache.messages : null;
|
|
1448
|
+
if (!messages || messages.length === 0) return;
|
|
1449
|
+
const shownMap = readJsonFileSafe(MESSAGES_SHOWN_PATH) || {};
|
|
1450
|
+
const msg = pickUnthrottledMessage(messages, shownMap, now);
|
|
1451
|
+
if (!msg) return;
|
|
1452
|
+
|
|
1453
|
+
const text = sanitizeMessageText(msg.text);
|
|
1454
|
+
if (!text) return;
|
|
1455
|
+
const out = source === 'claude_code'
|
|
1456
|
+
? { systemMessage: text }
|
|
1457
|
+
: { additional_context: text + CURSOR_RELAY_SUFFIX };
|
|
1458
|
+
|
|
1459
|
+
process.stdout.write(JSON.stringify(out));
|
|
1460
|
+
markShown(msg.id, messages.map(m => m && m.id).filter(Boolean), new Date(now).toISOString());
|
|
1461
|
+
} catch { /* never break capture */ }
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1382
1464
|
// =============================================================================
|
|
1383
1465
|
// Main
|
|
1384
1466
|
// =============================================================================
|
|
@@ -1435,6 +1517,12 @@ async function main() {
|
|
|
1435
1517
|
// hook invocation on the same machine.
|
|
1436
1518
|
const primary = events[0];
|
|
1437
1519
|
|
|
1520
|
+
// Render a server-driven update nudge ([0093]) on SessionStart — BEFORE the
|
|
1521
|
+
// project_filter / no_email / dedup gates below (those drop exactly the
|
|
1522
|
+
// stale/unknown devs we want to reach). Identity-independent, reads only the
|
|
1523
|
+
// local cache, never throws → can't flip this process to a non-zero exit.
|
|
1524
|
+
maybeEmitSessionStartMessage(primary);
|
|
1525
|
+
|
|
1438
1526
|
// Filter by monitored projects (if configured) — based on the primary event.
|
|
1439
1527
|
// If the primary is filtered out, drop the entire batch (the per-call events
|
|
1440
1528
|
// share the same project_path).
|
package/client/config.js
CHANGED
|
@@ -24,6 +24,10 @@ export const LOG_PATH = join(DATA_DIR, 'sender.log');
|
|
|
24
24
|
export const CAPTURE_LOG_PATH = join(DATA_DIR, 'capture.log');
|
|
25
25
|
export const SENDER_BACKOFF_PATH = join(DATA_DIR, 'sender-backoff.json');
|
|
26
26
|
export const LAST_STATUS_REPORT_PATH = join(DATA_DIR, 'last-status-report');
|
|
27
|
+
// Server-driven client messages ([0093]): sender caches them here; capture.js
|
|
28
|
+
// renders on SessionStart. `messages-shown` is the per-id throttle ledger.
|
|
29
|
+
export const MESSAGES_PATH = join(DATA_DIR, 'messages.json');
|
|
30
|
+
export const MESSAGES_SHOWN_PATH = join(DATA_DIR, 'messages-shown.json');
|
|
27
31
|
export const LOG_MAX_AGE_DAYS = 30;
|
|
28
32
|
const GIT_ROOT_CACHE = new Map();
|
|
29
33
|
// Pipe stderr (instead of inheriting it) so that "fatal: not a git repository"
|
|
@@ -154,6 +158,24 @@ export function getClientVersion() {
|
|
|
154
158
|
return { version: 'unknown', commit: 'unknown' };
|
|
155
159
|
}
|
|
156
160
|
|
|
161
|
+
/**
|
|
162
|
+
* Install mode of the RUNNING client — mirrors getClientVersion()'s three-way
|
|
163
|
+
* resolution so mode and version never disagree:
|
|
164
|
+
* 'copy' — sibling version.json (copied into ~/.ai-lens/client/ by init)
|
|
165
|
+
* 'repo' — running from the ai-lens package checkout (--use-repo-path / npx)
|
|
166
|
+
* 'unknown' — neither resolves
|
|
167
|
+
* Sent as X-Client-Mode so the server can return a mode-aware update nudge ([0093]).
|
|
168
|
+
*/
|
|
169
|
+
export function getClientMode() {
|
|
170
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
171
|
+
try { readFileSync(join(here, 'version.json'), 'utf-8'); return 'copy'; } catch { /* not a copy */ }
|
|
172
|
+
try {
|
|
173
|
+
const pkg = JSON.parse(readFileSync(resolve(here, '..', 'package.json'), 'utf-8'));
|
|
174
|
+
if (pkg?.name === 'ai-lens' && pkg.version) return 'repo';
|
|
175
|
+
} catch { /* not a checkout */ }
|
|
176
|
+
return 'unknown';
|
|
177
|
+
}
|
|
178
|
+
|
|
157
179
|
export function getGitIdentity(cwd) {
|
|
158
180
|
let email = null;
|
|
159
181
|
let name = null;
|
package/client/sender.js
CHANGED
|
@@ -41,6 +41,8 @@ import {
|
|
|
41
41
|
getServerUrl,
|
|
42
42
|
getAuthToken,
|
|
43
43
|
getClientVersion,
|
|
44
|
+
getClientMode,
|
|
45
|
+
MESSAGES_PATH,
|
|
44
46
|
DEFAULT_SERVER_URL,
|
|
45
47
|
log,
|
|
46
48
|
} from './config.js';
|
|
@@ -627,12 +629,32 @@ export function isTransientFetchError(err) {
|
|
|
627
629
|
return false;
|
|
628
630
|
}
|
|
629
631
|
|
|
632
|
+
/**
|
|
633
|
+
* Cache server-driven client messages ([0093]) for capture.js to render on
|
|
634
|
+
* SessionStart. `undefined` = no response parsed this run → leave cache as-is;
|
|
635
|
+
* `[]` = client is current → remove the cache (so a fixed client stops nudging);
|
|
636
|
+
* non-empty → atomic tmp+rename write. Best-effort: never break the flush.
|
|
637
|
+
*/
|
|
638
|
+
function cacheClientMessages(messages) {
|
|
639
|
+
if (!Array.isArray(messages)) return;
|
|
640
|
+
try {
|
|
641
|
+
if (messages.length === 0) {
|
|
642
|
+
try { unlinkSync(MESSAGES_PATH); } catch { /* already absent */ }
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
const tmp = MESSAGES_PATH + '.tmp.' + process.pid;
|
|
646
|
+
writeFileSync(tmp, JSON.stringify({ updatedAt: new Date().toISOString(), messages }));
|
|
647
|
+
renameSync(tmp, MESSAGES_PATH);
|
|
648
|
+
} catch { /* best-effort */ }
|
|
649
|
+
}
|
|
650
|
+
|
|
630
651
|
function buildHeaders(identity, authToken) {
|
|
631
652
|
const { version: clientVersion, commit: clientCommit } = getClientVersion();
|
|
632
653
|
const headers = {
|
|
633
654
|
'Content-Type': 'application/json',
|
|
634
655
|
'Connection': 'close',
|
|
635
656
|
'X-Client-Version': `${clientVersion}+${clientCommit}`,
|
|
657
|
+
'X-Client-Mode': getClientMode(),
|
|
636
658
|
};
|
|
637
659
|
if (identity.email) headers['X-Developer-Git-Email'] = identity.email;
|
|
638
660
|
if (identity.name) headers['X-Developer-Name'] = encodeURIComponent(identity.name);
|
|
@@ -849,6 +871,7 @@ async function main() {
|
|
|
849
871
|
}
|
|
850
872
|
|
|
851
873
|
const sentEventIds = new Set();
|
|
874
|
+
let lastMessages; // server-driven client messages ([0093]) from the last ok POST
|
|
852
875
|
|
|
853
876
|
try {
|
|
854
877
|
for (const { identity, events: batch } of byDeveloper.values()) {
|
|
@@ -857,6 +880,7 @@ async function main() {
|
|
|
857
880
|
for (const chunk of chunks) {
|
|
858
881
|
const result = await postEvents(serverUrl, chunk, identity, hasAuthToken ? authToken : null, { lockPath });
|
|
859
882
|
refreshLock(lockPath);
|
|
883
|
+
if (Array.isArray(result?.messages)) lastMessages = result.messages;
|
|
860
884
|
totalReceived += (result?.received ?? 0);
|
|
861
885
|
if ((result?.skipped ?? 0) > 0) {
|
|
862
886
|
log({ msg: 'server-skipped', skipped: result.skipped, chunk_size: chunk.length, developer: identity.email });
|
|
@@ -869,6 +893,7 @@ async function main() {
|
|
|
869
893
|
log({ msg: 'sent', events: totalReceived, chunks: chunks.length, developer: identity.email, projects, server: serverUrl });
|
|
870
894
|
}
|
|
871
895
|
clearSenderBackoff();
|
|
896
|
+
cacheClientMessages(lastMessages);
|
|
872
897
|
commitQueue(sendingDir, acquiredFiles);
|
|
873
898
|
trySpawnStatusReport();
|
|
874
899
|
} catch (err) {
|