ai-lens 0.8.48 → 0.8.51
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/README.md +16 -6
- package/cli/hooks.js +1 -1
- package/cli/init.js +57 -14
- package/cli/remove.js +18 -0
- package/cli/status.js +55 -5
- package/client/capture.js +49 -2
- package/client/codex-watcher.js +524 -0
- package/client/codex.js +523 -0
- package/client/config.js +46 -10
- package/client/sender.js +41 -1
- package/package.json +1 -1
package/.commithash
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
7c4d056
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# AI Lens
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Analytics for AI coding sessions. Captures hook events from Claude Code and Cursor, and near-real-time session events from Codex, normalizes them to a unified format, queues locally, and ships to a centralized server with a web dashboard.
|
|
4
4
|
|
|
5
5
|
```
|
|
6
6
|
Hook fires → capture.js → normalize → queue.jsonl → sender.js → POST /api/events → server → dashboard
|
|
@@ -15,9 +15,10 @@ npx -y ai-lens init
|
|
|
15
15
|
```
|
|
16
16
|
|
|
17
17
|
This will:
|
|
18
|
-
1. Detect installed AI tools (Claude Code, Cursor)
|
|
18
|
+
1. Detect installed AI tools (Claude Code, Cursor, Codex)
|
|
19
19
|
2. Copy client files to `~/.ai-lens/client/`
|
|
20
20
|
3. Configure hooks in `~/.claude/settings.json` and/or `~/.cursor/hooks.json`
|
|
21
|
+
4. Start the Codex watcher for user-level and project-local Codex sessions
|
|
21
22
|
|
|
22
23
|
Re-running is safe — it updates outdated hooks and skips current ones.
|
|
23
24
|
|
|
@@ -38,7 +39,7 @@ Configure the server URL and optionally filter projects:
|
|
|
38
39
|
```bash
|
|
39
40
|
# In your shell profile (~/.zshrc, ~/.bashrc)
|
|
40
41
|
export AI_LENS_SERVER_URL=http://your-server:13300
|
|
41
|
-
export AI_LENS_PROJECTS="~/work/, ~/projects/" # optional, default: all
|
|
42
|
+
export AI_LENS_PROJECTS="~/work/, ~/projects/" # optional, default: all; nested repos under these roots are included
|
|
42
43
|
```
|
|
43
44
|
|
|
44
45
|
<details>
|
|
@@ -76,6 +77,14 @@ export AI_LENS_PROJECTS="~/work/, ~/projects/" # optional, default: all
|
|
|
76
77
|
}
|
|
77
78
|
```
|
|
78
79
|
|
|
80
|
+
**Codex** — no hook file. Run `ai-lens init` to start the local watcher, or start it manually:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
node ~/.ai-lens/client/codex-watcher.js
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
The watcher tails the user-level Codex directory (`~/.codex`) and any project-local `.codex` directories found under `AI_LENS_PROJECTS`. It respects `AI_LENS_PROJECTS` the same way as Claude Code and Cursor: only sessions whose `cwd` is inside a configured monitored root are sent.
|
|
87
|
+
|
|
79
88
|
</details>
|
|
80
89
|
|
|
81
90
|
## Server Setup
|
|
@@ -103,11 +112,12 @@ Default credentials:
|
|
|
103
112
|
### Local development
|
|
104
113
|
|
|
105
114
|
```bash
|
|
115
|
+
docker compose up postgres -d
|
|
106
116
|
npm install --prefix server # Install server deps (auto-runs via prestart)
|
|
107
|
-
npm start
|
|
117
|
+
DATABASE_URL=postgresql://ailens:ailens@localhost:5432/ailens npm start
|
|
108
118
|
```
|
|
109
119
|
|
|
110
|
-
|
|
120
|
+
The server now requires PostgreSQL via `DATABASE_URL`. The old SQLite fallback is no longer supported.
|
|
111
121
|
|
|
112
122
|
## Dashboard
|
|
113
123
|
|
|
@@ -181,7 +191,7 @@ Aggregate endpoints for dashboard charts (stats, trends, tool usage, etc.).
|
|
|
181
191
|
| Variable | Default | Description |
|
|
182
192
|
|----------|---------|-------------|
|
|
183
193
|
| `PORT` | `3000` (local), `13300` (Docker) | Server port |
|
|
184
|
-
| `DATABASE_URL` | _(
|
|
194
|
+
| `DATABASE_URL` | _(required)_ | PostgreSQL connection string |
|
|
185
195
|
| `AI_LENS_SERVER_URL` | `http://localhost:3000` | Client → server endpoint |
|
|
186
196
|
| `AI_LENS_AUTH_TOKEN` | `collector:secret-collector-token-2026-ai-lens` | Client auth (`user:password`) |
|
|
187
197
|
| `AI_LENS_PROJECTS` | _(all)_ | Comma-separated project paths to monitor (`~` supported) |
|
package/cli/hooks.js
CHANGED
|
@@ -145,7 +145,7 @@ export function cursorCaptureCommand(useTilde = false, customPath = null) {
|
|
|
145
145
|
// Client file installation
|
|
146
146
|
// ---------------------------------------------------------------------------
|
|
147
147
|
|
|
148
|
-
const CLIENT_FILES = ['capture.js', 'sender.js', 'config.js', 'redact.js'];
|
|
148
|
+
const CLIENT_FILES = ['capture.js', 'sender.js', 'config.js', 'redact.js', 'codex.js', 'codex-watcher.js'];
|
|
149
149
|
|
|
150
150
|
/**
|
|
151
151
|
* Copy client/ files from the package source to ~/.ai-lens/client/.
|
package/cli/init.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createInterface } from 'node:readline';
|
|
2
2
|
import { execSync, spawn } from 'node:child_process';
|
|
3
3
|
import { existsSync, copyFileSync, readdirSync } from 'node:fs';
|
|
4
|
-
import { join, resolve, relative } from 'node:path';
|
|
4
|
+
import { join, resolve, relative, dirname } from 'node:path';
|
|
5
5
|
import { homedir } from 'node:os';
|
|
6
6
|
import { request as httpRequest } from 'node:http';
|
|
7
7
|
import { request as httpsRequest } from 'node:https';
|
|
@@ -146,6 +146,19 @@ function sleep(ms) {
|
|
|
146
146
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
+
function startCodexWatcher(watcherPath) {
|
|
150
|
+
if (!existsSync(watcherPath)) return false;
|
|
151
|
+
if (process.env.AI_LENS_TEST_NO_DETACHED_SPAWN === '1') return true;
|
|
152
|
+
const child = spawn(process.execPath, [watcherPath], {
|
|
153
|
+
detached: true,
|
|
154
|
+
stdio: 'ignore',
|
|
155
|
+
windowsHide: true,
|
|
156
|
+
});
|
|
157
|
+
child.on('error', () => {});
|
|
158
|
+
child.unref();
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
|
|
149
162
|
async function deviceCodeAuth(serverUrl) {
|
|
150
163
|
// 1. Fetch Auth0 config from server
|
|
151
164
|
let config;
|
|
@@ -386,14 +399,12 @@ export default async function init() {
|
|
|
386
399
|
}
|
|
387
400
|
|
|
388
401
|
if (tools.length === 0) {
|
|
389
|
-
warn('No
|
|
390
|
-
info('
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
for (const tool of tools) {
|
|
396
|
-
success(` Found: ${tool.name} (${tool.dirPath})`);
|
|
402
|
+
warn('No Claude Code or Cursor installation detected.');
|
|
403
|
+
info('Continuing with AI Lens client install, config, and Codex watcher setup.');
|
|
404
|
+
} else {
|
|
405
|
+
for (const tool of tools) {
|
|
406
|
+
success(` Found: ${tool.name} (${tool.dirPath})`);
|
|
407
|
+
}
|
|
397
408
|
}
|
|
398
409
|
|
|
399
410
|
// Configuration
|
|
@@ -419,19 +430,20 @@ export default async function init() {
|
|
|
419
430
|
|
|
420
431
|
// Project filter
|
|
421
432
|
const currentProjects = currentConfig.projects || null;
|
|
433
|
+
const projectHooksDefault = flags.projectHooks ? resolve(process.cwd()) : null;
|
|
422
434
|
let projects;
|
|
423
435
|
if (flags.projects) {
|
|
424
436
|
projects = flags.projects;
|
|
425
437
|
} else if (auto) {
|
|
426
|
-
projects = currentProjects;
|
|
438
|
+
projects = currentProjects || projectHooksDefault;
|
|
427
439
|
} else {
|
|
428
|
-
const projectsDefault = currentProjects || 'all';
|
|
440
|
+
const projectsDefault = currentProjects || projectHooksDefault || 'all';
|
|
429
441
|
const projectsInput = await ask(
|
|
430
442
|
`Projects to track (comma-separated, ~ supported, Enter = ${projectsDefault}): `,
|
|
431
443
|
);
|
|
432
444
|
projects = (projectsInput && projectsInput.trim() && projectsInput.trim().toLowerCase() !== 'all')
|
|
433
445
|
? projectsInput
|
|
434
|
-
: null;
|
|
446
|
+
: (projectHooksDefault || null);
|
|
435
447
|
}
|
|
436
448
|
// Guard: non-string (e.g. array from corrupt config) → treat as unset
|
|
437
449
|
if (projects && typeof projects !== 'string') {
|
|
@@ -530,8 +542,12 @@ export default async function init() {
|
|
|
530
542
|
}
|
|
531
543
|
}
|
|
532
544
|
|
|
533
|
-
if (flags.noHooks) {
|
|
534
|
-
|
|
545
|
+
if (flags.noHooks || tools.length === 0) {
|
|
546
|
+
if (tools.length === 0 && !flags.noHooks) {
|
|
547
|
+
info('No Claude Code or Cursor hooks to configure in this environment.');
|
|
548
|
+
} else if (flags.noHooks) {
|
|
549
|
+
info('--no-hooks: skipping hook configuration and MCP setup');
|
|
550
|
+
}
|
|
535
551
|
saveLensConfig(newConfig);
|
|
536
552
|
} else {
|
|
537
553
|
// Analyze each tool
|
|
@@ -727,6 +743,33 @@ export default async function init() {
|
|
|
727
743
|
warn(` Migration skipped: ${err.message}`);
|
|
728
744
|
}
|
|
729
745
|
|
|
746
|
+
heading('Codex');
|
|
747
|
+
let enableCodex = currentConfig.codexEnabled === true;
|
|
748
|
+
if (auto) {
|
|
749
|
+
enableCodex = true;
|
|
750
|
+
} else {
|
|
751
|
+
const defaultLabel = enableCodex ? 'Y/n' : 'y/N';
|
|
752
|
+
const answer = await ask(` Enable Codex session tracking? [${defaultLabel}] `);
|
|
753
|
+
if (answer) {
|
|
754
|
+
enableCodex = ['y', 'yes'].includes(answer.toLowerCase());
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
newConfig.codexEnabled = enableCodex;
|
|
758
|
+
saveLensConfig(newConfig);
|
|
759
|
+
|
|
760
|
+
if (enableCodex) {
|
|
761
|
+
const watcherPath = flags.useRepoPath
|
|
762
|
+
? join(dirname(REPO_CAPTURE_PATH), 'codex-watcher.js')
|
|
763
|
+
: join(dirname(CAPTURE_PATH), 'codex-watcher.js');
|
|
764
|
+
if (startCodexWatcher(watcherPath)) {
|
|
765
|
+
success(' Codex watcher started');
|
|
766
|
+
} else {
|
|
767
|
+
warn(` Codex watcher not started: missing ${watcherPath}`);
|
|
768
|
+
}
|
|
769
|
+
} else {
|
|
770
|
+
info(' Codex tracking disabled');
|
|
771
|
+
}
|
|
772
|
+
|
|
730
773
|
// Flush any pending events with the new config (e.g. backlog from previous 401/network errors)
|
|
731
774
|
const senderPath = join(homedir(), '.ai-lens', 'client', 'sender.js');
|
|
732
775
|
if (existsSync(senderPath)) {
|
package/cli/remove.js
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
buildStrippedConfig, writeHooksConfig, removeClientFiles, getVersionInfo,
|
|
13
13
|
cleanupLegacyHooks, cleanupEmptyMcpJson, removeCursorMcp,
|
|
14
14
|
} from './hooks.js';
|
|
15
|
+
import { stopCodexWatcher } from '../client/codex-watcher.js';
|
|
15
16
|
|
|
16
17
|
function ask(question) {
|
|
17
18
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
@@ -121,6 +122,23 @@ export default async function remove() {
|
|
|
121
122
|
}
|
|
122
123
|
blank();
|
|
123
124
|
|
|
125
|
+
// Stop Codex watcher before deleting ~/.ai-lens files so the process does not
|
|
126
|
+
// keep running against removed paths or leave a stale lock behind.
|
|
127
|
+
heading('Stopping Codex watcher...');
|
|
128
|
+
try {
|
|
129
|
+
const watcherResult = await stopCodexWatcher();
|
|
130
|
+
if (watcherResult.previousState === 'active') {
|
|
131
|
+
success(` Codex watcher stopped (pid ${watcherResult.pid}${watcherResult.forced ? ', forced' : ''})`);
|
|
132
|
+
} else if (watcherResult.previousState === 'missing') {
|
|
133
|
+
info(' Codex watcher not running');
|
|
134
|
+
} else {
|
|
135
|
+
success(` Codex watcher lock cleaned up (${watcherResult.previousState})`);
|
|
136
|
+
}
|
|
137
|
+
} catch (err) {
|
|
138
|
+
error(` Failed to stop Codex watcher: ${err.message}`);
|
|
139
|
+
}
|
|
140
|
+
blank();
|
|
141
|
+
|
|
124
142
|
// Remove MCP servers
|
|
125
143
|
heading('Removing MCP servers...');
|
|
126
144
|
|
package/cli/status.js
CHANGED
|
@@ -5,8 +5,9 @@ import { homedir, release as osRelease, arch as osArch } from 'node:os';
|
|
|
5
5
|
import { randomUUID } from 'node:crypto';
|
|
6
6
|
|
|
7
7
|
import { getVersionInfo, readLensConfig, detectInstalledTools, getCursorToolConfig, getClaudeCodeToolConfig, analyzeToolHooks, checkHooksDisabled, CAPTURE_PATH, TOOL_CONFIGS } from './hooks.js';
|
|
8
|
-
import { DATA_DIR, PENDING_DIR, SENDING_DIR, SESSION_PATHS_DIR, LOG_PATH, CAPTURE_LOG_PATH, getGitIdentity } from '../client/config.js';
|
|
8
|
+
import { DATA_DIR, PENDING_DIR, SENDING_DIR, SESSION_PATHS_DIR, LOG_PATH, CAPTURE_LOG_PATH, getGitIdentity, getMonitoredProjects } from '../client/config.js';
|
|
9
9
|
import { isLockStale } from '../client/sender.js';
|
|
10
|
+
import { readCodexWatcherLock, resolveWatchedCodexDirs } from '../client/codex-watcher.js';
|
|
10
11
|
import { initLogger, info, success, warn, error, heading, blank } from './logger.js';
|
|
11
12
|
|
|
12
13
|
const INIT_LOG_PATH = join(DATA_DIR, 'init.log');
|
|
@@ -223,14 +224,15 @@ function checkCaptureRun(installedTools) {
|
|
|
223
224
|
});
|
|
224
225
|
let shellOk = false;
|
|
225
226
|
let shellDetail = '';
|
|
227
|
+
// Declared outside try: GUI PATH test below uses isCursor + shellCommand (must stay in scope).
|
|
228
|
+
const isWin = process.platform === 'win32';
|
|
229
|
+
const isCursor = name === 'Cursor' || name.startsWith('Cursor (');
|
|
230
|
+
const usePS = isWin && isCursor;
|
|
231
|
+
let shellCommand = !isWin && command.startsWith('& ') ? command.slice(2).trim() : command;
|
|
226
232
|
try {
|
|
227
233
|
// On Windows, Cursor runs hooks via PowerShell (needs `& "..."` call-operator),
|
|
228
234
|
// but Claude Code runs hooks via bash/cmd (no `&` prefix).
|
|
229
235
|
// On non-Windows, strip leading "& " if present (e.g. config copied from Windows) so bash doesn't background the process.
|
|
230
|
-
const isWin = process.platform === 'win32';
|
|
231
|
-
const isCursor = name === 'Cursor' || name.startsWith('Cursor (');
|
|
232
|
-
const usePS = isWin && isCursor;
|
|
233
|
-
let shellCommand = !isWin && command.startsWith('& ') ? command.slice(2).trim() : command;
|
|
234
236
|
// Expand ~ in the command: bash/zsh won't expand ~ inside single quotes,
|
|
235
237
|
// so replace '~/...' and bare ~/... with the absolute home path.
|
|
236
238
|
if (!isWin) {
|
|
@@ -545,6 +547,51 @@ function checkQueue() {
|
|
|
545
547
|
return { ok, summary, detail, lineCount: pendingCount, lockStatus };
|
|
546
548
|
}
|
|
547
549
|
|
|
550
|
+
function pluralize(count, singular, plural = singular + 's') {
|
|
551
|
+
return `${count} ${count === 1 ? singular : plural}`;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
export function checkCodexWatcher({
|
|
555
|
+
dataDir = DATA_DIR,
|
|
556
|
+
monitoredRoots = getMonitoredProjects(),
|
|
557
|
+
homeDir = homedir(),
|
|
558
|
+
userCodexDirs,
|
|
559
|
+
} = {}) {
|
|
560
|
+
const watchedDirs = resolveWatchedCodexDirs(monitoredRoots, userCodexDirs, homeDir);
|
|
561
|
+
const lock = readCodexWatcherLock(join(dataDir, 'codex-watcher.lock'));
|
|
562
|
+
const watchedSummary = pluralize(watchedDirs.length, 'watched dir');
|
|
563
|
+
|
|
564
|
+
if (lock.state === 'active') {
|
|
565
|
+
return {
|
|
566
|
+
ok: true,
|
|
567
|
+
summary: `active (pid ${lock.pid}, ${watchedSummary})`,
|
|
568
|
+
detail: `Lock: ${lock.lockPath}\nPID: ${lock.pid}\nWatched dirs:\n${watchedDirs.map(dir => `- ${dir}`).join('\n') || '(none discovered yet)'}`,
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
if (lock.state === 'stale') {
|
|
573
|
+
return {
|
|
574
|
+
ok: false,
|
|
575
|
+
summary: `stale lock (pid ${lock.pid}, ${watchedSummary})`,
|
|
576
|
+
detail: `Lock: ${lock.lockPath}\nStale PID: ${lock.pid}\nWatched dirs:\n${watchedDirs.map(dir => `- ${dir}`).join('\n') || '(none discovered yet)'}`,
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
if (lock.state === 'invalid' || lock.state === 'error') {
|
|
581
|
+
return {
|
|
582
|
+
ok: false,
|
|
583
|
+
summary: `lock ${lock.state} (${watchedSummary})`,
|
|
584
|
+
detail: `Lock: ${lock.lockPath}\nError: ${lock.error || '(none)'}\nWatched dirs:\n${watchedDirs.map(dir => `- ${dir}`).join('\n') || '(none discovered yet)'}`,
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
return {
|
|
589
|
+
ok: watchedDirs.length > 0 ? false : null,
|
|
590
|
+
summary: `not running (${watchedSummary})`,
|
|
591
|
+
detail: `Lock: ${lock.lockPath}\nWatched dirs:\n${watchedDirs.map(dir => `- ${dir}`).join('\n') || '(none discovered yet)'}`,
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
|
|
548
595
|
function checkSenderLog() {
|
|
549
596
|
if (!existsSync(LOG_PATH)) {
|
|
550
597
|
return { ok: null, summary: 'no log file', detail: 'Sender log does not exist (sender has not run yet)' };
|
|
@@ -1062,6 +1109,9 @@ export default async function status() {
|
|
|
1062
1109
|
const configResult = checkConfig();
|
|
1063
1110
|
printLine('Config', configResult);
|
|
1064
1111
|
|
|
1112
|
+
// 5b. Codex watcher
|
|
1113
|
+
printLine('Codex watcher', checkCodexWatcher());
|
|
1114
|
+
|
|
1065
1115
|
// 6. Hooks: global + project (Cursor then Claude Code; within each: global then project)
|
|
1066
1116
|
const installedTools = detectInstalledTools();
|
|
1067
1117
|
const toolsWithProject = getToolsForCaptureTest();
|
package/client/capture.js
CHANGED
|
@@ -13,19 +13,24 @@ import { spawn } from 'node:child_process';
|
|
|
13
13
|
import { fileURLToPath } from 'node:url';
|
|
14
14
|
import { createHash } from 'node:crypto';
|
|
15
15
|
import {
|
|
16
|
+
DATA_DIR,
|
|
16
17
|
ensureDataDir,
|
|
17
18
|
PENDING_DIR,
|
|
19
|
+
SENDING_DIR,
|
|
18
20
|
DEDUP_DIR,
|
|
19
21
|
SESSION_PATHS_DIR,
|
|
20
22
|
CAPTURE_LOG_PATH,
|
|
21
23
|
LOG_MAX_AGE_DAYS,
|
|
24
|
+
SENDER_BACKOFF_PATH,
|
|
22
25
|
captureLog,
|
|
23
26
|
getServerUrl,
|
|
24
27
|
getAuthToken,
|
|
25
28
|
getGitIdentity,
|
|
26
29
|
getGitMetadata,
|
|
27
30
|
getMonitoredProjects,
|
|
31
|
+
isCodexEnabled,
|
|
28
32
|
} from './config.js';
|
|
33
|
+
import { isLockStale, isSenderBackoffActive } from './sender.js';
|
|
29
34
|
// Soft import — redact.js may not exist on older client installs
|
|
30
35
|
let redactObject = (o) => o;
|
|
31
36
|
try {
|
|
@@ -619,7 +624,7 @@ export function normalizeEvent(event) {
|
|
|
619
624
|
// Queue + Sender Spawn
|
|
620
625
|
// =============================================================================
|
|
621
626
|
|
|
622
|
-
function writeToSpool(unified) {
|
|
627
|
+
export function writeToSpool(unified) {
|
|
623
628
|
// Shallow copy before redaction — do not mutate the caller's object
|
|
624
629
|
const toWrite = { ...unified };
|
|
625
630
|
// Redact secrets from individual string values (not serialized JSON)
|
|
@@ -645,7 +650,14 @@ function writeToSpool(unified) {
|
|
|
645
650
|
|
|
646
651
|
// Always try to spawn sender — renameSync in sender acts as the real mutex.
|
|
647
652
|
// If sender is already running, the new one exits immediately (ENOENT on rename).
|
|
648
|
-
function
|
|
653
|
+
export function shouldSpawnSender(lockPath = join(SENDING_DIR, '.sender.lock'), backoffPath = SENDER_BACKOFF_PATH, nowMs = Date.now()) {
|
|
654
|
+
if (isSenderBackoffActive(backoffPath, nowMs)) return false;
|
|
655
|
+
if (existsSync(lockPath) && !isLockStale(lockPath)) return false;
|
|
656
|
+
return true;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
export function trySpawnSender() {
|
|
660
|
+
if (!shouldSpawnSender()) return;
|
|
649
661
|
const senderPath = join(__dirname, 'sender.js');
|
|
650
662
|
const child = spawn(process.execPath, [senderPath], {
|
|
651
663
|
detached: true,
|
|
@@ -656,6 +668,35 @@ function trySpawnSender() {
|
|
|
656
668
|
child.unref();
|
|
657
669
|
}
|
|
658
670
|
|
|
671
|
+
function hasLivePidLock(lockPath) {
|
|
672
|
+
try {
|
|
673
|
+
const pid = Number.parseInt(readFileSync(lockPath, 'utf-8').trim().split(/\r?\n/)[0], 10);
|
|
674
|
+
if (!pid) return false;
|
|
675
|
+
process.kill(pid, 0);
|
|
676
|
+
return true;
|
|
677
|
+
} catch (err) {
|
|
678
|
+
return err?.code === 'EPERM';
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
export function shouldSpawnCodexWatcher(lockPath = join(DATA_DIR, 'codex-watcher.lock')) {
|
|
683
|
+
return !hasLivePidLock(lockPath);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function trySpawnCodexWatcher() {
|
|
687
|
+
if (!isCodexEnabled()) return;
|
|
688
|
+
if (!shouldSpawnCodexWatcher()) return;
|
|
689
|
+
const watcherPath = join(__dirname, 'codex-watcher.js');
|
|
690
|
+
if (!existsSync(watcherPath)) return;
|
|
691
|
+
const child = spawn(process.execPath, [watcherPath], {
|
|
692
|
+
detached: true,
|
|
693
|
+
stdio: 'ignore',
|
|
694
|
+
windowsHide: true,
|
|
695
|
+
});
|
|
696
|
+
child.on('error', () => {});
|
|
697
|
+
child.unref();
|
|
698
|
+
}
|
|
699
|
+
|
|
659
700
|
// =============================================================================
|
|
660
701
|
// Main
|
|
661
702
|
// =============================================================================
|
|
@@ -770,6 +811,12 @@ async function main() {
|
|
|
770
811
|
captureLog({ msg: 'sender-spawn-failed', error: err.message });
|
|
771
812
|
// event is queued — sender will be spawned on next capture
|
|
772
813
|
}
|
|
814
|
+
|
|
815
|
+
try {
|
|
816
|
+
trySpawnCodexWatcher();
|
|
817
|
+
} catch (err) {
|
|
818
|
+
captureLog({ msg: 'codex-watcher-spawn-failed', error: err.message });
|
|
819
|
+
}
|
|
773
820
|
}
|
|
774
821
|
|
|
775
822
|
// Only run main when executed directly (not when imported for testing).
|