ai-lens 0.8.51 → 0.8.52

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 CHANGED
@@ -1 +1 @@
1
- 7c4d056
1
+ 8553371
package/README.md CHANGED
@@ -1,6 +1,8 @@
1
1
  # AI Lens
2
2
 
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.
3
+ Analytics for AI coding sessions. Captures hook events from Claude Code, Cursor,
4
+ and Codex, normalizes them to a unified format, queues locally, and ships to a
5
+ centralized server with a web dashboard and MCP integration.
4
6
 
5
7
  ```
6
8
  Hook fires → capture.js → normalize → queue.jsonl → sender.js → POST /api/events → server → dashboard
@@ -19,6 +21,7 @@ This will:
19
21
  2. Copy client files to `~/.ai-lens/client/`
20
22
  3. Configure hooks in `~/.claude/settings.json` and/or `~/.cursor/hooks.json`
21
23
  4. Start the Codex watcher for user-level and project-local Codex sessions
24
+ 5. Register the MCP server for in-editor analytics (optional)
22
25
 
23
26
  Re-running is safe — it updates outdated hooks and skips current ones.
24
27
 
@@ -27,19 +30,38 @@ Re-running is safe — it updates outdated hooks and skips current ones.
27
30
  To write hooks into the project directory (`.cursor/hooks.json` and `.claude/settings.json`) instead of global `~/.cursor/` and `~/.claude/`, run from the project root:
28
31
 
29
32
  ```bash
30
- npx git+ssh://git@rantsports.gitlab.yandexcloud.net:ai-first-workspace/internal/analytics/ai-lens.git init --server https://ai-lens.rantsports.com --no-mcp --project-hooks
33
+ npx -y ai-lens init --server https://ai-lens.rantsports.com --no-mcp --project-hooks
31
34
  ```
32
35
 
33
36
  Add `--use-repo-path` to run `capture.js` directly from the package (repo or npx cache) instead of copying to `~/.ai-lens/client/`. Useful when the repo is next to the workspace.
34
37
 
35
- Hooks use the path `~/.ai-lens/client/capture.js` by default (or the package path with `--use-repo-path`); the config can be committed to the repo.
38
+ ### CLI commands
36
39
 
37
- Configure the server URL and optionally filter projects:
40
+ ```bash
41
+ npx ai-lens init # Setup wizard — detect tools, install hooks, configure MCP
42
+ npx ai-lens status # Run health checks and generate a diagnostic report
43
+ npx ai-lens remove # Remove hooks, client files, and MCP config
44
+ npx ai-lens version # Show installed version
45
+ ```
46
+
47
+ ### CLI options
48
+
49
+ | Flag | Description |
50
+ |------|-------------|
51
+ | `--server URL` | Server URL (default: `http://localhost:3000`) |
52
+ | `--yes`, `-y` | Non-interactive mode, accept all defaults |
53
+ | `--no-mcp` | Skip MCP server registration |
54
+ | `--mcp-scope SCOPE` | MCP scope: `user` (default), `local`, or `project` |
55
+ | `--projects LIST` | Comma-separated project paths to monitor (default: all) |
56
+ | `--project-hooks` | Write hooks into project directory instead of global config |
57
+ | `--use-repo-path` | Run capture.js from package path instead of copying to `~/.ai-lens/client/` |
58
+
59
+ ### Environment variables (client)
38
60
 
39
61
  ```bash
40
62
  # In your shell profile (~/.zshrc, ~/.bashrc)
41
- export AI_LENS_SERVER_URL=http://your-server:13300
42
- export AI_LENS_PROJECTS="~/work/, ~/projects/" # optional, default: all; nested repos under these roots are included
63
+ export AI_LENS_SERVER_URL=https://ai-lens.rantsports.com
64
+ export AI_LENS_PROJECTS="~/meta/, ~/meta-cursor/" # optional, default: all
43
65
  ```
44
66
 
45
67
  <details>
@@ -96,47 +118,94 @@ docker compose up -d
96
118
  ```
97
119
 
98
120
  Starts three containers:
99
- - **nginx** — reverse proxy with basic auth, port `13300`
100
- - **app** — Node.js Express server with dashboard
101
- - **postgres** — PostgreSQL 16 database
102
121
 
103
- Dashboard: `http://your-server:13300`
122
+ | Container | Image | Purpose |
123
+ |-----------|-------|---------|
124
+ | **app** | `ai-lens/app` | API server + web dashboard (port 3000) |
125
+ | **postgres** | `postgres:16-alpine` | PostgreSQL 16 database |
126
+ | **analyzer** | `ai-lens/analyzer` | Background session analyzer (needs `claude login`) |
127
+
128
+ Dashboard: https://ai-lens.rantsports.com
104
129
 
105
- Default credentials:
130
+ Images are stored in ECR (`267996409571.dkr.ecr.eu-north-1.amazonaws.com/ai-lens/`) and mirrored to GHCR (`ghcr.io/r-ms/ai-lens/`) on every push to `main`.
106
131
 
107
- | User | Password | Purpose |
108
- |------|----------|---------|
109
- | `collector` | `secret-collector-token-2026-ai-lens` | Client sender (automatic via `AI_LENS_AUTH_TOKEN`) |
110
- | `meta` | `meta` | Browser / dashboard access |
132
+ ### Environment variables (server)
133
+
134
+ | Variable | Default | Description |
135
+ |----------|---------|-------------|
136
+ | `PORT` | `3000` | Server port |
137
+ | `DATABASE_URL` | _(required)_ | PostgreSQL connection string |
138
+ | `POSTGRES_PASSWORD` | `ailens` | PostgreSQL password (docker compose) |
139
+ | `ANALYSIS_INTERVAL` | `3600` | Seconds between analysis runs |
140
+ | `AI_LENS_ADMIN_SECRET` | _(none)_ | Admin secret for auth token management |
141
+ | `OPENAI_API_KEY` | _(none)_ | OpenAI API key for FAISS vector search; text search works without it |
142
+ | `TEAMS_CONFIG` | _(none)_ | JSON config for team definitions |
143
+ | `CORS_ALLOWED_ORIGINS` | _(none)_ | Allowed CORS origins |
144
+
145
+ #### Auth0 SSO
146
+
147
+ | Variable | Description |
148
+ |----------|-------------|
149
+ | `AUTH0_DOMAIN` | Auth0 tenant domain |
150
+ | `AUTH0_CLIENT_ID` | Auth0 SPA client ID |
151
+ | `AUTH0_AUDIENCE` | Auth0 API audience identifier |
152
+ | `AUTH0_ALLOWED_DOMAIN` | Restrict login to a specific email domain |
153
+ | `AUTH0_CLI_CLIENT_ID` | Auth0 Native app client ID for device code flow |
154
+ | `MCP_SERVER_URL` | Public server URL for MCP OAuth callbacks |
155
+
156
+ Without Auth0, the server uses git email headers for identity (personal mode).
111
157
 
112
158
  ### Local development
113
159
 
114
160
  ```bash
115
161
  docker compose up postgres -d
116
- npm install --prefix server # Install server deps (auto-runs via prestart)
162
+ npm install
117
163
  DATABASE_URL=postgresql://ailens:ailens@localhost:5432/ailens npm start
118
164
  ```
119
165
 
120
- The server now requires PostgreSQL via `DATABASE_URL`. The old SQLite fallback is no longer supported.
121
-
122
166
  ## Dashboard
123
167
 
124
- React + TypeScript SPA with session timelines, tool breakdowns, adoption trends, and per-developer analytics.
168
+ React + TypeScript SPA with:
169
+ - Organization-wide KPIs and adoption trends
170
+ - Team and developer breakdowns
171
+ - Session timelines with tool usage
172
+ - AI-generated session and team analyses
173
+ - Token usage by model
174
+ - MCP server and skill distribution
175
+ - Knowledge base and recurring problems
125
176
 
126
177
  ```bash
127
178
  cd dashboard
128
179
  npm install
129
180
  npm run dev # Vite dev server with HMR (proxies API to localhost:3000)
130
- ```
131
-
132
- Production build (served by Express as static files):
133
-
134
- ```bash
135
- npm run build:dashboard
181
+ npm run build # Production build (served by Express as static files)
136
182
  ```
137
183
 
138
184
  Tech: Vite, Tailwind CSS, Nivo charts, TanStack Query, react-router-dom.
139
185
 
186
+ ## MCP Tools
187
+
188
+ When MCP is enabled during `npx ai-lens init`, these tools become available inside Claude Code and Cursor:
189
+
190
+ | Tool | Description |
191
+ |------|-------------|
192
+ | `who_am_i` | Identify yourself by git email — returns your developer profile and team(s) |
193
+ | `get_overview` | Organization-wide KPIs: active developers, adoption rate, AI hours, MCP and skill distribution |
194
+ | `list_teams` | List all teams with member counts, adoption rate, and AI hours |
195
+ | `get_team` | Team detail: KPIs, members, tasks, activity trend, MCP and skill distribution |
196
+ | `get_team_analysis` | AI-generated team analysis: achievements, recurring problems, recommendations |
197
+ | `get_developer` | Developer profile: sessions, AI hours, tasks, MCP and skill usage, team comparison |
198
+ | `get_mcp_distribution` | MCP server usage across the organization |
199
+ | `get_chain` | Session chain with compact event timeline, plan mode segments, and timing |
200
+ | `get_events` | Full event data for specific event IDs |
201
+ | `get_chain_analysis` | AI-generated chain analysis: tasks, problems, tool errors, unanswered questions |
202
+ | `request_analysis` | Manually trigger analysis for a specific session chain |
203
+ | `get_token_usage` | Token usage statistics grouped by model (input/output/cache tokens) |
204
+ | `knowhow_search` | Search the team knowledge base built from session analyses |
205
+ | `knowhow_update` | Add or update a knowledge base entry |
206
+ | `export_developer_tips` | Export personalized tips as a Markdown document |
207
+ | `search` | Natural language search across sessions, tasks, and projects |
208
+
140
209
  ## API
141
210
 
142
211
  ### `POST /api/events`
@@ -144,7 +213,7 @@ Tech: Vite, Tailwind CSS, Nivo charts, TanStack Query, react-router-dom.
144
213
  Batch insert events. Deduplicates by `event_id` (ON CONFLICT DO NOTHING) — safe to re-send.
145
214
 
146
215
  ```
147
- Headers: X-Developer-Git-Email, X-Developer-Name, Authorization: Basic <base64>
216
+ Headers: X-Developer-Git-Email, X-Developer-Name, X-Auth-Token
148
217
  Body: [{ source, session_id, type, project_path, timestamp, data, raw, event_id }]
149
218
  Response: { received, skipped, deduplicated }
150
219
  ```
@@ -163,38 +232,36 @@ List all developers.
163
232
 
164
233
  ### `GET /api/dashboard/*`
165
234
 
166
- Aggregate endpoints for dashboard charts (stats, trends, tool usage, etc.).
235
+ Aggregate endpoints for dashboard charts: overview, teams, developers, tokens, MCP distribution, developer activity, knowledge base, problems, company/team/developer analyses.
167
236
 
168
237
  ## Event Types
169
238
 
170
239
  | Type | Source | Description |
171
240
  |------|--------|-------------|
172
- | `SessionStart` | Both | Session opened |
173
- | `SessionEnd` | Both | Session closed |
174
- | `UserPromptSubmit` | Both | User sent a prompt |
175
- | `PostToolUse` | Both | Tool execution completed |
176
- | `PostToolUseFailure` | Both | Tool execution failed |
177
- | `Stop` | Both | Agent stopped |
178
- | `PreCompact` | Both | Context compaction triggered |
241
+ | `SessionStart` | All | Session opened |
242
+ | `SessionEnd` | All | Session closed |
243
+ | `UserPromptSubmit` | All | User sent a prompt |
244
+ | `PostToolUse` | All | Tool execution completed |
245
+ | `PostToolUseFailure` | All | Tool execution failed |
246
+ | `Stop` | All | Agent stopped |
247
+ | `PreCompact` | All | Context compaction triggered |
179
248
  | `PlanModeStart` | Claude Code | Entered plan mode |
180
249
  | `PlanModeEnd` | Claude Code | Exited plan mode (plan content in raw payload) |
181
- | `SubagentStart` | Both | Subagent spawned |
182
- | `SubagentStop` | Both | Subagent finished |
250
+ | `SubagentStart` | All | Subagent spawned |
251
+ | `SubagentStop` | All | Subagent finished |
183
252
  | `FileEdit` | Cursor | File edited |
184
253
  | `ShellExecution` | Cursor | Shell command executed |
185
254
  | `MCPExecution` | Cursor | MCP tool executed |
186
- | `AgentResponse` | Cursor | Agent response |
255
+ | `AgentResponse` | Cursor | Agent response (includes token usage in raw payload) |
187
256
  | `AgentThought` | Cursor | Agent reasoning |
188
257
 
189
- ## Environment Variables
258
+ ## Supported Tools
190
259
 
191
- | Variable | Default | Description |
192
- |----------|---------|-------------|
193
- | `PORT` | `3000` (local), `13300` (Docker) | Server port |
194
- | `DATABASE_URL` | _(required)_ | PostgreSQL connection string |
195
- | `AI_LENS_SERVER_URL` | `http://localhost:3000` | Client server endpoint |
196
- | `AI_LENS_AUTH_TOKEN` | `collector:secret-collector-token-2026-ai-lens` | Client auth (`user:password`) |
197
- | `AI_LENS_PROJECTS` | _(all)_ | Comma-separated project paths to monitor (`~` supported) |
260
+ | Tool | Hook mechanism |
261
+ |------|---------------|
262
+ | **Claude Code** | Hooks via `~/.claude/settings.json` |
263
+ | **Cursor** | Hooks via `~/.cursor/hooks.json` |
264
+ | **Codex** | File watcher on `~/.codex` and project-local `.codex` directories |
198
265
 
199
266
  ## Client Data
200
267
 
@@ -203,41 +270,35 @@ Stored in `~/.ai-lens/`:
203
270
  | File | Purpose |
204
271
  |------|---------|
205
272
  | `client/` | Installed client files (capture.js, sender.js, config.js) |
273
+ | `config.json` | Server URL, auth token, project list |
206
274
  | `queue.jsonl` | Pending events |
207
275
  | `queue.sending.jsonl` | Events being sent (atomic rename as mutex) |
208
276
  | `sender.log` | Sender activity log |
277
+ | `capture.log` | Capture drop log (normalization failures, write errors) |
209
278
  | `session-paths.json` | Session-to-project path cache |
210
279
 
211
280
  ## Development
212
281
 
213
282
  ```bash
214
- npm test # Run all tests (vitest, 204 tests)
283
+ npm test # Run all tests (vitest, 683 tests)
215
284
  npm run test:watch # Watch mode
216
285
  npm run dev:dashboard # Dashboard dev server
217
286
  ```
218
287
 
219
- Tests use in-memory SQLite via `initTestDb()`.
288
+ Tests require PostgreSQL — set `DATABASE_URL` or use `docker compose up postgres -d` (test DB `ailens_test` is created automatically).
220
289
 
221
290
  ## Deployment
222
291
 
223
292
  GitLab CI (`.gitlab-ci.yml`) on push to `main`:
224
293
 
225
- 1. `rsync` to deploy host
226
- 2. `docker compose down && docker compose up -d --build`
227
- 3. Health check
228
-
229
- ## Data Migration
230
-
231
- Sync local SQLite data to a remote PostgreSQL server:
232
-
233
- ```bash
234
- node scripts/sync-to-remote.js # Default remote
235
- node scripts/sync-to-remote.js http://custom:13300 # Custom URL
236
- ```
294
+ 1. **build-app** — builds `ai-lens/app` Docker image, pushes to ECR + GHCR
295
+ 2. **build-analyzer** — builds `ai-lens/analyzer` Docker image, pushes to ECR + GHCR
296
+ 3. **deploy** — zero-downtime rolling deploy to production (scale up new container, health check, remove old)
237
297
 
238
- Safe to re-run deduplicates by `event_id`.
298
+ Build jobs trigger only when relevant files change (Dockerfile, server/**, dashboard/**, etc.).
239
299
 
240
300
  ## Requirements
241
301
 
242
302
  - Node.js 20+
243
303
  - Docker + Docker Compose (for production deployment)
304
+ - PostgreSQL 16 (for local development without Docker)
package/cli/hooks.js CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync, lstatSync, readFileSync, writeFileSync, copyFileSync, renameSync, mkdirSync, rmSync, unlinkSync, chmodSync } from 'node:fs';
1
+ import { existsSync, lstatSync, readFileSync, writeFileSync, copyFileSync, renameSync, mkdirSync, rmSync, unlinkSync, chmodSync, readdirSync } from 'node:fs';
2
2
  import { join, dirname } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
4
  import { fileURLToPath } from 'node:url';
@@ -145,7 +145,16 @@ 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', 'codex.js', 'codex-watcher.js'];
148
+ /**
149
+ * Enumerate every .js file that ships with the package's client/ directory.
150
+ * Dynamic discovery (rather than a hardcoded CLIENT_FILES list) guarantees
151
+ * that any new client/*.js file added to the repo is automatically installed,
152
+ * so a forgotten list entry can never produce a broken install that crashes
153
+ * every hook with ERR_MODULE_NOT_FOUND on a missing sibling import.
154
+ */
155
+ export function listClientFiles(sourceDir = join(__dirname, '..', 'client')) {
156
+ return readdirSync(sourceDir).filter(f => f.endsWith('.js')).sort();
157
+ }
149
158
 
150
159
  /**
151
160
  * Copy client/ files from the package source to ~/.ai-lens/client/.
@@ -155,7 +164,7 @@ export function installClientFiles() {
155
164
  const sourceDir = join(__dirname, '..', 'client');
156
165
  mkdirSync(CLIENT_INSTALL_DIR, { recursive: true });
157
166
 
158
- for (const file of CLIENT_FILES) {
167
+ for (const file of listClientFiles(sourceDir)) {
159
168
  copyFileSync(join(sourceDir, file), join(CLIENT_INSTALL_DIR, file));
160
169
  }
161
170
 
package/cli/init.js CHANGED
@@ -67,7 +67,7 @@ function getJson(url) {
67
67
  });
68
68
  }
69
69
 
70
- function postJson(url, body) {
70
+ function postJson(url, body, timeoutMs = 15_000) {
71
71
  return new Promise((resolve, reject) => {
72
72
  const parsed = new URL(url);
73
73
  const isHttps = parsed.protocol === 'https:';
@@ -82,7 +82,7 @@ function postJson(url, body) {
82
82
  'Content-Type': 'application/json',
83
83
  'Content-Length': Buffer.byteLength(data),
84
84
  },
85
- timeout: 15_000,
85
+ timeout: timeoutMs,
86
86
  };
87
87
  const req = requestFn(options, (res) => {
88
88
  let buf = '';
@@ -107,7 +107,7 @@ function postJson(url, body) {
107
107
  });
108
108
  }
109
109
 
110
- function postForm(url, params) {
110
+ function postForm(url, params, timeoutMs = 15_000) {
111
111
  return new Promise((resolve, reject) => {
112
112
  const parsed = new URL(url);
113
113
  const isHttps = parsed.protocol === 'https:';
@@ -122,7 +122,7 @@ function postForm(url, params) {
122
122
  'Content-Type': 'application/x-www-form-urlencoded',
123
123
  'Content-Length': Buffer.byteLength(data),
124
124
  },
125
- timeout: 15_000,
125
+ timeout: timeoutMs,
126
126
  };
127
127
  const req = requestFn(options, (res) => {
128
128
  let buf = '';
@@ -146,6 +146,26 @@ function sleep(ms) {
146
146
  return new Promise(resolve => setTimeout(resolve, ms));
147
147
  }
148
148
 
149
+ function isTransientNetError(err) {
150
+ const msg = `${err && err.code ? err.code : ''} ${err && err.message ? err.message : err}`;
151
+ return /ECONNRESET|EPIPE|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|ECONNREFUSED|socket hang up/i.test(String(msg));
152
+ }
153
+
154
+ /** Retry fn on flaky TLS/proxy resets (common during long Auth0 device polls). */
155
+ async function withRetries(fn, { attempts = 5, baseDelayMs = 2000 } = {}) {
156
+ let lastErr;
157
+ for (let i = 1; i <= attempts; i++) {
158
+ try {
159
+ return await fn();
160
+ } catch (err) {
161
+ lastErr = err;
162
+ if (!isTransientNetError(err) || i === attempts) throw err;
163
+ await sleep(baseDelayMs * i);
164
+ }
165
+ }
166
+ throw lastErr;
167
+ }
168
+
149
169
  function startCodexWatcher(watcherPath) {
150
170
  if (!existsSync(watcherPath)) return false;
151
171
  if (process.env.AI_LENS_TEST_NO_DETACHED_SPAWN === '1') return true;
@@ -219,17 +239,24 @@ async function deviceCodeAuth(serverUrl) {
219
239
  while (Date.now() < deadline) {
220
240
  await sleep(interval * 1000);
221
241
 
222
- const tokenResp = await postForm(`https://${domain}/oauth/token`, {
223
- grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
224
- client_id: cliClientId,
225
- device_code,
226
- });
242
+ // Auth0 may keep this request open longer than a typical HTTP call; 15s caused false "Request timed out".
243
+ const tokenResp = await withRetries(
244
+ () => postForm(`https://${domain}/oauth/token`, {
245
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
246
+ client_id: cliClientId,
247
+ device_code,
248
+ }, 120_000),
249
+ { attempts: 6, baseDelayMs: 2000 },
250
+ );
227
251
 
228
252
  if (tokenResp.status === 200 && tokenResp.data.id_token) {
229
253
  // 5. Exchange JWT for personal token
230
- const result = await postJson(`${serverUrl}/api/auth/device-token`, {
231
- jwt: tokenResp.data.id_token,
232
- });
254
+ const result = await withRetries(
255
+ () => postJson(`${serverUrl}/api/auth/device-token`, {
256
+ jwt: tokenResp.data.id_token,
257
+ }, 120_000),
258
+ { attempts: 5, baseDelayMs: 2000 },
259
+ );
233
260
  if (!result?.token) throw new Error('Server returned no token — contact your admin');
234
261
  return result;
235
262
  }
@@ -510,10 +537,11 @@ export default async function init() {
510
537
  try {
511
538
  authResult = await deviceCodeAuth(serverUrl);
512
539
  } catch (err) {
513
- if (err.message.includes('not configured')) {
540
+ const msg = (err && err.message) ? err.message : String(err);
541
+ if (msg.includes('not configured')) {
514
542
  warn(` Auth not configured on server — personal mode (events sent via git identity)`);
515
543
  } else {
516
- warn(` Authentication failed: ${err.message}`);
544
+ warn(` Authentication failed: ${msg}`);
517
545
  warn(` Run "npx -y ai-lens init" again later to authenticate`);
518
546
  }
519
547
  }
package/client/capture.js CHANGED
@@ -7,7 +7,7 @@
7
7
  * normalizes to unified event format, appends to queue, spawns sender if needed.
8
8
  */
9
9
 
10
- import { readFileSync, writeFileSync, appendFileSync, existsSync, unlinkSync, renameSync, realpathSync } from 'node:fs';
10
+ import { readFileSync, writeFileSync, appendFileSync, existsSync, unlinkSync, renameSync, realpathSync, statSync, openSync, readSync, closeSync, readdirSync } from 'node:fs';
11
11
  import { join, dirname } from 'node:path';
12
12
  import { spawn } from 'node:child_process';
13
13
  import { fileURLToPath } from 'node:url';
@@ -19,6 +19,7 @@ import {
19
19
  SENDING_DIR,
20
20
  DEDUP_DIR,
21
21
  SESSION_PATHS_DIR,
22
+ TRANSCRIPT_OFFSETS_DIR,
22
23
  CAPTURE_LOG_PATH,
23
24
  LOG_MAX_AGE_DAYS,
24
25
  SENDER_BACKOFF_PATH,
@@ -31,6 +32,7 @@ import {
31
32
  isCodexEnabled,
32
33
  } from './config.js';
33
34
  import { isLockStale, isSenderBackoffActive } from './sender.js';
35
+ import { toNumberOrNull, buildTokenUsageRaw } from './token-usage.js';
34
36
  // Soft import — redact.js may not exist on older client installs
35
37
  let redactObject = (o) => o;
36
38
  try {
@@ -154,6 +156,287 @@ function truncateToolResult(result, toolName) {
154
156
  return result;
155
157
  }
156
158
 
159
+ /**
160
+ * Read the persisted byte offset + size + mtime for a transcript file.
161
+ * Returns null on any error (caller treats as "start from 0").
162
+ */
163
+ function readTranscriptOffsetState(offsetFile) {
164
+ try {
165
+ const parsed = JSON.parse(readFileSync(offsetFile, 'utf-8'));
166
+ if (
167
+ parsed &&
168
+ typeof parsed.offset === 'number' &&
169
+ typeof parsed.size === 'number' &&
170
+ typeof parsed.mtime_ms === 'number'
171
+ ) {
172
+ return parsed;
173
+ }
174
+ return null;
175
+ } catch {
176
+ return null;
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Atomically persist the byte offset + size + mtime for a transcript file.
182
+ * Mirrors the tmp+rename pattern used by cacheSessionPath.
183
+ */
184
+ function writeTranscriptOffsetState(offsetFile, state) {
185
+ ensureDataDir();
186
+ const tmp = offsetFile + '.tmp.' + process.pid;
187
+ try {
188
+ writeFileSync(tmp, JSON.stringify(state));
189
+ try {
190
+ renameSync(tmp, offsetFile);
191
+ } catch {
192
+ try { unlinkSync(tmp); } catch {}
193
+ }
194
+ } catch { /* best-effort: a missed offset write means we re-read one Stop worth */ }
195
+ }
196
+
197
+ // Run stale-offset cleanup at most once per 24h per machine. Each unique
198
+ // transcript path produces a persistent offset file; without this pass the
199
+ // directory grows unbounded over months. We rate-limit via the mtime of a
200
+ // marker file so the common path of every Stop hook is a single statSync.
201
+ const TRANSCRIPT_OFFSETS_CLEANUP_INTERVAL_MS = 24 * 60 * 60 * 1000;
202
+
203
+ function maybeCleanStaleTranscriptOffsets() {
204
+ const marker = join(TRANSCRIPT_OFFSETS_DIR, '.last_cleanup');
205
+ let shouldRun = true;
206
+ try {
207
+ const st = statSync(marker);
208
+ if (Date.now() - st.mtimeMs < TRANSCRIPT_OFFSETS_CLEANUP_INTERVAL_MS) {
209
+ shouldRun = false;
210
+ }
211
+ } catch {
212
+ // Marker missing — first run on this machine, proceed.
213
+ }
214
+ if (!shouldRun) return;
215
+
216
+ // Touch the marker FIRST so concurrent Stop hooks within the same second
217
+ // don't both kick off the readdir scan. ensureDataDir() in case the dir
218
+ // doesn't exist yet on a fresh install.
219
+ ensureDataDir();
220
+ try {
221
+ writeFileSync(marker, '');
222
+ } catch {
223
+ // If we can't write the marker we still try the scan, but the rate limit
224
+ // won't take effect — which is acceptable best-effort behavior.
225
+ }
226
+
227
+ let entries = [];
228
+ try {
229
+ entries = readdirSync(TRANSCRIPT_OFFSETS_DIR);
230
+ } catch {
231
+ return;
232
+ }
233
+
234
+ for (const name of entries) {
235
+ if (name.startsWith('.')) continue; // skip .last_cleanup and other dotfiles
236
+ let originalPath;
237
+ try {
238
+ originalPath = decodeURIComponent(name);
239
+ } catch {
240
+ continue;
241
+ }
242
+ try {
243
+ if (!existsSync(originalPath)) {
244
+ unlinkSync(join(TRANSCRIPT_OFFSETS_DIR, name));
245
+ }
246
+ } catch {
247
+ // Ignore per-entry errors so one stuck file doesn't block the pass.
248
+ }
249
+ }
250
+ }
251
+
252
+ /**
253
+ * Incrementally read only the NEW bytes appended to a Claude Code transcript
254
+ * since the last time this function was called for that transcript, and
255
+ * return ONE entry per real (non-synthetic) assistant API call in the delta.
256
+ *
257
+ * Each entry corresponds to a single Anthropic API call: a single assistant
258
+ * line in the JSONL with its own usage record. A single user->agent turn can
259
+ * produce many of these (tool-use loops), and the caller is expected to emit
260
+ * one unified TokenUsage event per entry — symmetric with Cursor's per-call
261
+ * AgentResponse rows and Codex's per-call TokenUsage rows.
262
+ *
263
+ * Returns { calls, commitOffset }:
264
+ * - `calls` is Array<{usage, model, timestamp, uuid}>:
265
+ * • `usage` has Anthropic-named keys (input_tokens, output_tokens,
266
+ * cache_read_input_tokens, cache_creation_input_tokens) for
267
+ * buildTokenUsageRaw.
268
+ * • `model` is the model from that specific assistant line.
269
+ * • `timestamp` is the assistant line's own ISO timestamp if present, else
270
+ * null (caller falls back to the Stop event's timestamp).
271
+ * • `uuid` is the assistant line's `uuid` field (Claude Code transcripts
272
+ * always include one). Used by the caller to derive a stable event_id
273
+ * so concurrent Stop hook invocations can't double-emit a single API call.
274
+ * - `commitOffset` is a no-arg function the caller MUST invoke ONLY after
275
+ * successfully writing every TokenUsage event derived from `calls` into
276
+ * the spool. If spool writes fail (or the caller never calls it), the
277
+ * transcript cursor stays put and the next Stop re-reads the same delta —
278
+ * the server-side ON CONFLICT on the deterministic event_id then dedups
279
+ * whichever rows did land. This is what makes the pipeline crash-safe:
280
+ * we never mark transcript bytes "consumed" until we know the rows they
281
+ * produced are durably queued.
282
+ *
283
+ * Returns an empty `calls` array (and a no-op `commitOffset`) if nothing new
284
+ * in the delta or no real assistant lines are found.
285
+ *
286
+ * Partial-line handling: a Stop hook can fire while Claude Code is mid-write,
287
+ * so the delta may end before a trailing '\n'. We detect this, ignore the
288
+ * partial tail, and persist the offset at the byte right after the LAST
289
+ * complete '\n' so the next Stop re-reads the partial line once it's finished.
290
+ */
291
+ function extractNewClaudeApiCallsFromTranscript(transcriptPath) {
292
+ // Run the rate-limited stale-offset cleanup at most once per 24h. Wrapped in
293
+ // try/catch so a cleanup failure never prevents the token read.
294
+ try { maybeCleanStaleTranscriptOffsets(); } catch { /* best-effort */ }
295
+
296
+ // Default: no-op commit. Callers always receive a function so they never
297
+ // have to null-check before invoking.
298
+ const noopCommit = () => {};
299
+
300
+ if (!transcriptPath || typeof transcriptPath !== 'string') {
301
+ return { calls: [], commitOffset: noopCommit };
302
+ }
303
+
304
+ const offsetFile = join(TRANSCRIPT_OFFSETS_DIR, encodeURIComponent(transcriptPath));
305
+
306
+ let currentSize = 0;
307
+ let currentMtime = 0;
308
+ try {
309
+ const st = statSync(transcriptPath);
310
+ currentSize = st.size;
311
+ currentMtime = st.mtimeMs;
312
+ } catch (err) {
313
+ captureLog({ msg: 'transcript-offset-error', stage: 'stat', path: transcriptPath, error: err?.message });
314
+ return { calls: [], commitOffset: noopCommit };
315
+ }
316
+
317
+ const savedState = readTranscriptOffsetState(offsetFile);
318
+ let startOffset = 0;
319
+ if (savedState) {
320
+ // Detect rotation/truncation: file shrank below our saved cursor, or file
321
+ // didn't grow but the mtime changed (content rewrite in place).
322
+ if (currentSize < savedState.offset) {
323
+ startOffset = 0;
324
+ } else if (currentSize === savedState.offset && currentMtime !== savedState.mtime_ms) {
325
+ startOffset = 0;
326
+ } else {
327
+ startOffset = savedState.offset;
328
+ }
329
+ }
330
+
331
+ // Nothing new — just a stat, super cheap.
332
+ if (currentSize === startOffset) {
333
+ return { calls: [], commitOffset: noopCommit };
334
+ }
335
+
336
+ let buffer = null;
337
+ try {
338
+ const length = currentSize - startOffset;
339
+ const fd = openSync(transcriptPath, 'r');
340
+ try {
341
+ buffer = Buffer.alloc(length);
342
+ readSync(fd, buffer, 0, length, startOffset);
343
+ } finally {
344
+ closeSync(fd);
345
+ }
346
+ } catch (err) {
347
+ captureLog({ msg: 'transcript-offset-error', stage: 'read', path: transcriptPath, error: err?.message });
348
+ // Read failed before we got any bytes — leave the cursor unchanged so the
349
+ // next Stop retries. (Old behavior advanced past the unread bytes; that
350
+ // silently dropped data on transient I/O errors.)
351
+ return { calls: [], commitOffset: noopCommit };
352
+ }
353
+
354
+ if (!buffer || buffer.length === 0) {
355
+ // Defensive: shouldn't happen because of the currentSize === startOffset
356
+ // early return above. Don't advance the cursor.
357
+ return { calls: [], commitOffset: noopCommit };
358
+ }
359
+
360
+ // Find the LAST complete newline. CRITICAL: search the BYTE buffer, not a
361
+ // decoded string — string indices use UTF-16 code units, but file offsets
362
+ // are byte-based. Mixing the two corrupts startOffset on non-ASCII content
363
+ // (Russian transcripts, emoji, etc.) and causes lines to be replayed on the
364
+ // next Stop, double-counting tokens.
365
+ //
366
+ // 0x0A is the byte value of '\n' (always single-byte in UTF-8 — '\n' is
367
+ // ASCII, so this byte never appears inside a multi-byte sequence).
368
+ const lastNewlineIndex = buffer.lastIndexOf(0x0A);
369
+ if (lastNewlineIndex === -1) {
370
+ // The entire chunk is a partial trailing line — leave the cursor unchanged
371
+ // so the next Stop re-reads the line once it's finished.
372
+ return { calls: [], commitOffset: noopCommit };
373
+ }
374
+
375
+ // Process only the bytes up to (and including) the last '\n'. The new
376
+ // persisted offset points at the byte AFTER that newline so any partial
377
+ // trailing line is replayed on the next Stop. Decode just the processable
378
+ // slice as UTF-8 once for line iteration.
379
+ const processable = buffer.slice(0, lastNewlineIndex).toString('utf-8');
380
+ const newOffset = startOffset + lastNewlineIndex + 1;
381
+ const nextState = { offset: newOffset, size: currentSize, mtime_ms: currentMtime };
382
+ const commitOffset = () => writeTranscriptOffsetState(offsetFile, nextState);
383
+
384
+ // Iterate lines in the processable region. Defensive parse-tolerance is
385
+ // still useful for the rare case where a single line in the middle is
386
+ // malformed (we skip it rather than dropping the whole delta).
387
+ const calls = [];
388
+ try {
389
+ const lines = processable.split('\n');
390
+ for (const rawLine of lines) {
391
+ const line = rawLine && rawLine.trim();
392
+ if (!line) continue;
393
+ let parsed;
394
+ try {
395
+ parsed = JSON.parse(line);
396
+ } catch {
397
+ continue;
398
+ }
399
+ if (parsed?.type !== 'assistant') continue;
400
+ const message = parsed?.message;
401
+ if (!message || typeof message !== 'object') continue;
402
+ const model = message.model;
403
+ // Skip <synthetic> internal placeholder entries — they carry all-zero
404
+ // usage and would not show up on billing.
405
+ if (!model || model === '<synthetic>') continue;
406
+ const usage = message.usage;
407
+ if (!usage || typeof usage !== 'object') continue;
408
+ // Capture each call as its own entry — the caller emits one unified
409
+ // TokenUsage event per entry, matching Cursor/Codex per-call granularity.
410
+ calls.push({
411
+ usage: {
412
+ input_tokens: toNumberOrNull(usage.input_tokens) ?? 0,
413
+ output_tokens: toNumberOrNull(usage.output_tokens) ?? 0,
414
+ cache_read_input_tokens: toNumberOrNull(usage.cache_read_input_tokens) ?? 0,
415
+ cache_creation_input_tokens: toNumberOrNull(usage.cache_creation_input_tokens) ?? 0,
416
+ },
417
+ model,
418
+ timestamp: typeof parsed.timestamp === 'string' ? parsed.timestamp : null,
419
+ uuid: typeof parsed.uuid === 'string' ? parsed.uuid : null,
420
+ });
421
+ }
422
+ } catch (err) {
423
+ captureLog({ msg: 'transcript-offset-error', stage: 'parse', path: transcriptPath, error: err?.message });
424
+ // On a parse-loop exception, advance the cursor to the last complete
425
+ // newline so we don't spin re-reading the same bytes. There are no calls
426
+ // to spool in this path, so it's safe to commit immediately (no risk of
427
+ // losing events).
428
+ commitOffset();
429
+ return { calls: [], commitOffset: noopCommit };
430
+ }
431
+
432
+ // IMPORTANT: do NOT persist the offset here. The caller is responsible for
433
+ // invoking `commitOffset` only AFTER it has successfully written every
434
+ // TokenUsage event derived from `calls` to the spool. Advancing the cursor
435
+ // eagerly (the previous behavior) would silently drop rows whenever a
436
+ // writeToSpool() failed after we'd already "consumed" the transcript bytes.
437
+ return { calls, commitOffset };
438
+ }
439
+
157
440
  // =============================================================================
158
441
  // Source Detection
159
442
  // =============================================================================
@@ -315,6 +598,19 @@ const PLAN_MODE_TOOLS = {
315
598
  ExitPlanMode: 'PlanModeEnd',
316
599
  };
317
600
 
601
+ /**
602
+ * Normalize a Claude Code hook event into one or more unified events.
603
+ *
604
+ * Returns an array — almost every hook produces a single primary event, but
605
+ * Stop and SubagentStop additionally read NEW assistant API calls from the
606
+ * transcript and emit one TokenUsage event per call (one assistant line in
607
+ * the transcript = one API call = one row in the events table). This keeps
608
+ * row granularity symmetric with Cursor (one AgentResponse per API call) and
609
+ * Codex (one TokenUsage per token_count record).
610
+ *
611
+ * Returns an empty array for hooks that should be silently dropped (e.g. a
612
+ * non-plan-mode PreToolUse).
613
+ */
318
614
  function normalizeClaudeCode(event) {
319
615
  const sessionId = event.session_id;
320
616
  const hookType = event.hook_event_name || event.hook;
@@ -342,7 +638,7 @@ function normalizeClaudeCode(event) {
342
638
  const preToolName = event.tool_name || event.tool || 'unknown';
343
639
  // Only capture PreToolUse for plan mode tools; skip others to avoid duplicating PostToolUse
344
640
  if (!PLAN_MODE_TOOLS[preToolName]) {
345
- return null;
641
+ return [];
346
642
  }
347
643
  type = PLAN_MODE_TOOLS[preToolName];
348
644
  data = { tool: preToolName };
@@ -417,7 +713,7 @@ function normalizeClaudeCode(event) {
417
713
  data.permission_mode = event.permission_mode;
418
714
  }
419
715
 
420
- return {
716
+ const primary = {
421
717
  event_id: null,
422
718
  source: 'claude_code',
423
719
  session_id: sessionId,
@@ -427,6 +723,46 @@ function normalizeClaudeCode(event) {
427
723
  data,
428
724
  raw: event,
429
725
  };
726
+
727
+ // For Stop and SubagentStop, also read the transcript delta and emit one
728
+ // TokenUsage event per real assistant API call. This is what makes Claude
729
+ // Code's row-per-call granularity match Cursor and Codex.
730
+ //
731
+ // Claude Code's Stop hook passes the path as `transcript_path`, but
732
+ // SubagentStop uses `agent_transcript_path` (see Claude Code hook docs and
733
+ // test/fixtures/claude-code-events/subagent-stop.json). Check both so
734
+ // subagent token usage is not silently dropped in production.
735
+ if (hookType === 'Stop' || hookType === 'SubagentStop') {
736
+ const transcriptPath = event.transcript_path || event.agent_transcript_path;
737
+ const { calls, commitOffset } = extractNewClaudeApiCallsFromTranscript(transcriptPath);
738
+ const tokenEvents = calls.map(call => ({
739
+ event_id: null, // assigned in main() from a stable hash of call.uuid
740
+ source: 'claude_code',
741
+ session_id: sessionId,
742
+ type: 'TokenUsage',
743
+ project_path: projectPath,
744
+ timestamp: call.timestamp || timestamp,
745
+ data: {
746
+ model: call.model,
747
+ input_tokens: call.usage.input_tokens,
748
+ output_tokens: call.usage.output_tokens,
749
+ },
750
+ raw: buildTokenUsageRaw({ source_uuid: call.uuid }, call.usage, call.model),
751
+ }));
752
+ const result = [primary, ...tokenEvents];
753
+ // Attach the commit callback as a non-enumerable property on the returned
754
+ // array so it survives through normalizeEvent() without leaking into
755
+ // iteration (for...of, .map, writeToSpool's {...spread}) or into tests
756
+ // that assert on events.length / events[0].
757
+ Object.defineProperty(result, 'commitTranscriptOffset', {
758
+ value: commitOffset,
759
+ enumerable: false,
760
+ writable: false,
761
+ });
762
+ return result;
763
+ }
764
+
765
+ return [primary];
430
766
  }
431
767
 
432
768
  // =============================================================================
@@ -498,6 +834,7 @@ function normalizeCursor(event) {
498
834
  }
499
835
 
500
836
  let data = {};
837
+ let raw = event;
501
838
  switch (hookName) {
502
839
  case 'sessionStart':
503
840
  data = { workspace_roots: event.workspace_roots };
@@ -565,6 +902,7 @@ function normalizeCursor(event) {
565
902
  break;
566
903
  case 'afterAgentResponse':
567
904
  data = { text: truncate(event.text || '', TRUNCATION_LIMITS.agentResponse) };
905
+ raw = buildTokenUsageRaw(event, event.usage || null, event.model || null);
568
906
  break;
569
907
  case 'afterAgentThought':
570
908
  data = {
@@ -595,7 +933,7 @@ function normalizeCursor(event) {
595
933
  data.permission_mode = event.permission_mode;
596
934
  }
597
935
 
598
- return {
936
+ return [{
599
937
  event_id: null,
600
938
  source: 'cursor',
601
939
  session_id: sessionId,
@@ -603,20 +941,27 @@ function normalizeCursor(event) {
603
941
  project_path: projectPath,
604
942
  timestamp,
605
943
  data,
606
- raw: event,
607
- };
944
+ raw,
945
+ }];
608
946
  }
609
947
 
610
948
  // =============================================================================
611
949
  // Normalize (dispatcher)
612
950
  // =============================================================================
613
951
 
952
+ /**
953
+ * Normalize a raw hook event into an array of unified events. Almost every
954
+ * hook produces a single primary event, but Claude Code Stop/SubagentStop
955
+ * additionally emit one TokenUsage event per API call read from the
956
+ * transcript delta. Returns an empty array for hooks that should be silently
957
+ * dropped (e.g. non-plan-mode PreToolUse) or an unrecognized source.
958
+ */
614
959
  export function normalizeEvent(event) {
615
960
  const source = detectSource(event);
616
961
  switch (source) {
617
962
  case 'claude_code': return normalizeClaudeCode(event);
618
963
  case 'cursor': return normalizeCursor(event);
619
- default: return null;
964
+ default: return [];
620
965
  }
621
966
  }
622
967
 
@@ -740,69 +1085,135 @@ async function main() {
740
1085
  process.exit(0);
741
1086
  }
742
1087
 
743
- const unified = normalizeEvent(event);
744
- if (!unified || !unified.session_id) {
1088
+ const events = normalizeEvent(event);
1089
+ if (!events || events.length === 0 || !events[0].session_id) {
745
1090
  logDrop('normalize_failed', { hook: event.hook_event_name });
746
1091
  process.exit(0);
747
1092
  }
748
1093
 
749
- // Deterministic event_id from raw stdin both hooks receive identical bytes,
750
- // so they produce the same ID and ON CONFLICT(event_id) catches the duplicate.
751
- unified.event_id = deterministicEventId(input);
1094
+ // The primary event (always at index 0) carries the canonical session/type
1095
+ // for project-filter, identity, and dedup decisions. Additional events
1096
+ // (Claude Code per-call TokenUsage rows) inherit the same project, identity,
1097
+ // and git metadata from the primary, since they all originate from the same
1098
+ // hook invocation on the same machine.
1099
+ const primary = events[0];
752
1100
 
753
- // Filter by monitored projects (if configured)
1101
+ // Filter by monitored projects (if configured) — based on the primary event.
1102
+ // If the primary is filtered out, drop the entire batch (the per-call events
1103
+ // share the same project_path).
754
1104
  const monitored = getMonitoredProjects();
755
- let projectPath = unified.project_path;
1105
+ let projectPath = primary.project_path;
756
1106
  try { projectPath = realpathSync(projectPath); } catch {}
757
1107
  if (monitored && projectPath && !monitored.some(p => pathContains(p, projectPath))) {
758
1108
  // Fallback: for Cursor multi-root workspaces, check if any raw workspace_roots entry matches
759
1109
  const roots = Array.isArray(event.workspace_roots) ? event.workspace_roots : [];
760
1110
  const resolvedRoots = roots.map(r => { try { return realpathSync(r); } catch { return r; } });
761
1111
  if (!resolvedRoots.some(root => monitored.some(p => pathContains(p, root)))) {
762
- logDrop('project_filter', { type: unified.type, source: unified.source, session_id: unified.session_id, project_path: unified.project_path, monitored });
1112
+ logDrop('project_filter', { type: primary.type, source: primary.source, session_id: primary.session_id, project_path: primary.project_path, monitored });
763
1113
  process.exit(0);
764
1114
  }
765
1115
  }
766
1116
 
767
1117
  // Resolve identity: git first, then fall back to event payload (e.g. Cursor's user_email)
768
1118
  // When auth token is present, server resolves developer from token — email is optional
769
- const identity = getGitIdentity(unified.project_path);
1119
+ const identity = getGitIdentity(primary.project_path);
770
1120
  const token = getAuthToken();
771
1121
  const hasAuthToken = typeof token === 'string' && token.startsWith('ailens_dev_');
772
1122
  const resolved = resolveIdentity(identity, event, hasAuthToken);
773
1123
  if (!resolved.proceed) {
774
- logDrop('no_email', { type: unified.type, session_id: unified.session_id });
1124
+ logDrop('no_email', { type: primary.type, session_id: primary.session_id });
775
1125
  process.exit(0);
776
1126
  }
777
1127
 
778
1128
  // Deduplicate consecutive identical event types (e.g. repeated Stop from idle sessions).
1129
+ // Only the PRIMARY event participates in this check; per-call TokenUsage events
1130
+ // have their own stable event_id (derived from the assistant line uuid), so
1131
+ // server-side ON CONFLICT(event_id) handles their dedup naturally.
779
1132
  // Placed after project_filter and no_email checks so dropped events don't poison the cache.
780
- // checkDuplicate is a pure read — does NOT commit. commitDedup is called only AFTER a
781
- // successful queue write, preventing cache poisoning: if appendToQueue throws, the event
782
- // is lost but checkDuplicate will still return false on the next attempt (allowing retry).
783
- if (checkDuplicate(unified.session_id, unified.source, unified.type)) {
784
- logDrop('duplicate', { type: unified.type, session_id: unified.session_id });
1133
+ if (checkDuplicate(primary.session_id, primary.source, primary.type)) {
1134
+ logDrop('duplicate', { type: primary.type, session_id: primary.session_id });
785
1135
  process.exit(0);
786
1136
  }
787
- unified.developer_email = resolved.email;
788
- unified.developer_name = resolved.name;
789
1137
 
790
- // Attach git metadata (remote, branch, commit)
791
- const gitMeta = getGitMetadata(unified.project_path);
792
- unified.git_remote = gitMeta.git_remote;
793
- unified.git_branch = gitMeta.git_branch;
794
- unified.git_commit = gitMeta.git_commit;
1138
+ // Attach git metadata once every event in the batch shares it.
1139
+ const gitMeta = getGitMetadata(primary.project_path);
1140
+
1141
+ // Assign event_ids:
1142
+ // - Primary: deterministic from stdin hash (so Cursor + Claude Code firing
1143
+ // the same hook compute the same id and dedup at ON CONFLICT).
1144
+ // - Per-call TokenUsage: stable hash of the assistant line's uuid (so two
1145
+ // concurrent Stop hooks reading the same lines compute the same id and
1146
+ // dedup at the same UNIQUE constraint).
1147
+ primary.event_id = deterministicEventId(input);
1148
+ for (let i = 1; i < events.length; i++) {
1149
+ const ev = events[i];
1150
+ const sourceUuid = ev.raw && ev.raw.source_uuid;
1151
+ if (sourceUuid) {
1152
+ ev.event_id = deterministicEventId(`claude_code:tokenusage:${sourceUuid}`);
1153
+ } else {
1154
+ // Fallback: stdin hash + per-event index. Should never be needed because
1155
+ // Claude Code transcripts always include uuid per record.
1156
+ ev.event_id = deterministicEventId(`${input}:tokenusage:${i}`);
1157
+ }
1158
+ }
795
1159
 
796
- // Write to spool (pending/ dir)
797
- try {
798
- writeToSpool(unified);
799
- } catch (err) {
800
- captureLog({ msg: 'queue-write-failed', error: err.message, type: unified.type, session_id: unified.session_id });
801
- process.exit(1);
1160
+ // Write every event in the batch to the spool, attaching shared metadata.
1161
+ let primaryWritten = false;
1162
+ let allTokenUsageWritten = true;
1163
+ for (const ev of events) {
1164
+ ev.developer_email = resolved.email;
1165
+ ev.developer_name = resolved.name;
1166
+ ev.git_remote = gitMeta.git_remote;
1167
+ ev.git_branch = gitMeta.git_branch;
1168
+ ev.git_commit = gitMeta.git_commit;
1169
+ try {
1170
+ writeToSpool(ev);
1171
+ if (ev === primary) primaryWritten = true;
1172
+ } catch (err) {
1173
+ captureLog({ msg: 'queue-write-failed', error: err.message, type: ev.type, session_id: ev.session_id });
1174
+ // If the primary failed, propagate the failure so the hook exits non-zero
1175
+ // (and dedup is NOT committed). If a per-call event failed, log and keep
1176
+ // going — losing one TokenUsage row is better than dropping the whole
1177
+ // turn, but we'll also refuse to commit the transcript offset below so
1178
+ // the next Stop re-reads the same delta (server-side dedup on event_id
1179
+ // handles the already-succeeded rows).
1180
+ if (ev === primary) process.exit(1);
1181
+ if (ev.type === 'TokenUsage') allTokenUsageWritten = false;
1182
+ }
802
1183
  }
803
1184
 
804
- // Commit dedup only after successful queue write prevents cache poisoning on write failure.
805
- commitDedup(unified.session_id, unified.source, unified.type);
1185
+ // Commit dedup AND the transcript offset together, only if every event in
1186
+ // the batch (primary + every per-call TokenUsage) made it into the spool.
1187
+ //
1188
+ // Why gate dedup on allTokenUsageWritten too:
1189
+ // If primary spooled but a per-call TokenUsage write failed, committing
1190
+ // dedup would cause the next back-to-back idle Stop to be dropped as a
1191
+ // duplicate — and since that drop happens BEFORE the transcript re-read,
1192
+ // the missing TokenUsage row would stay lost until some other event type
1193
+ // resets the dedup cache. By deferring dedup until every per-call write
1194
+ // succeeds, we guarantee the next Stop (even another idle Stop in the
1195
+ // same session) will re-read the delta and retry the failed rows. The
1196
+ // primary Stop gets spooled twice in that scenario, but its deterministic
1197
+ // event_id hits server-side ON CONFLICT — idempotent.
1198
+ //
1199
+ // Why gate the transcript offset on the same condition:
1200
+ // Advancing the cursor marks those transcript bytes "consumed". If any
1201
+ // TokenUsage row derived from them is missing from the spool, we must
1202
+ // re-read those bytes on the next Stop. Server-side ON CONFLICT on the
1203
+ // deterministic event_id (hashed from the assistant line's uuid) dedups
1204
+ // whichever rows did land, and the missing row(s) finally get through.
1205
+ const batchFullySpooled = primaryWritten && allTokenUsageWritten;
1206
+ if (batchFullySpooled) {
1207
+ commitDedup(primary.session_id, primary.source, primary.type);
1208
+ if (typeof events.commitTranscriptOffset === 'function') {
1209
+ try {
1210
+ events.commitTranscriptOffset();
1211
+ } catch (err) {
1212
+ captureLog({ msg: 'transcript-offset-commit-failed', error: err?.message });
1213
+ // Not fatal: on the next Stop we'll just re-read the same delta.
1214
+ }
1215
+ }
1216
+ }
806
1217
 
807
1218
  // Always try to spawn sender — atomic rename in sender handles dedup
808
1219
  try {
package/client/codex.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { existsSync, realpathSync } from 'node:fs';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { dirname, join } from 'node:path';
4
+ import { toNumberOrNull } from './token-usage.js';
4
5
 
5
6
  const TRUNCATION_LIMITS = {
6
7
  toolInput: { command: 500, old_string: 200, new_string: 200, default: 200 },
@@ -179,6 +180,7 @@ function streamStateFor(state, streamKey) {
179
180
  projectPath: null,
180
181
  pendingCalls: new Map(),
181
182
  hasActivity: false,
183
+ model: null,
182
184
  });
183
185
  }
184
186
  return state.streams.get(streamKey);
@@ -188,6 +190,20 @@ function sessionTimestamp(record) {
188
190
  return record?.timestamp || new Date().toISOString();
189
191
  }
190
192
 
193
+ function buildCodexTokenUsageRaw(record, last, model) {
194
+ const inputTokens = toNumberOrNull(last?.input_tokens);
195
+ const outputTokens = toNumberOrNull(last?.output_tokens);
196
+ const cachedInputTokens = toNumberOrNull(last?.cached_input_tokens);
197
+
198
+ return {
199
+ ...record,
200
+ ...(model ? { model } : {}),
201
+ ...(inputTokens != null ? { input_tokens: inputTokens } : {}),
202
+ ...(outputTokens != null ? { output_tokens: outputTokens } : {}),
203
+ ...(cachedInputTokens != null ? { cache_read_tokens: cachedInputTokens } : {}),
204
+ };
205
+ }
206
+
191
207
  function buildUnifiedEvent(stream, type, timestamp, data, raw) {
192
208
  if (!stream.sessionId || !stream.projectPath) return null;
193
209
  return {
@@ -313,6 +329,7 @@ export function normalizeCodexSessionEntries(record, state, streamKey = 'default
313
329
  stream.rawSessionId = sessionId;
314
330
  stream.projectPath = cwd;
315
331
  stream.hasActivity = false;
332
+ stream.model = null;
316
333
  events.push(buildUnifiedEvent(
317
334
  stream,
318
335
  'SessionStart',
@@ -338,8 +355,38 @@ export function normalizeCodexSessionEntries(record, state, streamKey = 'default
338
355
  return events.filter(Boolean);
339
356
  }
340
357
 
358
+ if (record?.type === 'turn_context') {
359
+ const nextModel = record?.payload?.model;
360
+ if (nextModel) stream.model = nextModel;
361
+ return [];
362
+ }
363
+
341
364
  if (!stream.sessionId || !stream.projectPath) return [];
342
365
 
366
+ if (record?.type === 'event_msg' && record?.payload?.type === 'token_count') {
367
+ const info = record?.payload?.info;
368
+ if (!info) return [];
369
+ const last = info.last_token_usage;
370
+ if (!last) return [];
371
+ const inputTokens = toNumberOrNull(last.input_tokens);
372
+ const outputTokens = toNumberOrNull(last.output_tokens);
373
+ if (inputTokens == null && outputTokens == null) return [];
374
+ stream.hasActivity = true;
375
+ return [buildUnifiedEvent(
376
+ stream,
377
+ 'TokenUsage',
378
+ sessionTimestamp(record),
379
+ {
380
+ input_tokens: inputTokens,
381
+ output_tokens: outputTokens,
382
+ cached_input_tokens: toNumberOrNull(last.cached_input_tokens),
383
+ reasoning_output_tokens: toNumberOrNull(last.reasoning_output_tokens),
384
+ model: stream.model || null,
385
+ },
386
+ buildCodexTokenUsageRaw(record, last, stream.model),
387
+ )].filter(Boolean);
388
+ }
389
+
343
390
  if (record?.type === 'response_item') {
344
391
  const payload = record.payload || {};
345
392
 
package/client/config.js CHANGED
@@ -17,13 +17,25 @@ export const CURRENT_STORAGE_VERSION = 1;
17
17
  export const QUEUE_PATH = join(DATA_DIR, 'queue.jsonl');
18
18
  export const SENDING_PATH = join(DATA_DIR, 'queue.sending.jsonl');
19
19
  export const SESSION_PATHS_DIR = join(DATA_DIR, 'session-paths');
20
+ export const TRANSCRIPT_OFFSETS_DIR = join(DATA_DIR, 'transcript-offsets');
20
21
  export const GIT_REMOTES_DIR = join(DATA_DIR, 'git-remotes');
21
22
  export const LOG_PATH = join(DATA_DIR, 'sender.log');
22
23
  export const CAPTURE_LOG_PATH = join(DATA_DIR, 'capture.log');
23
24
  export const SENDER_BACKOFF_PATH = join(DATA_DIR, 'sender-backoff.json');
24
25
  export const LOG_MAX_AGE_DAYS = 30;
25
26
  const GIT_ROOT_CACHE = new Map();
26
- let _gitRunner = (args, options) => childProcess.execFileSync('git', args, options);
27
+ // Pipe stderr (instead of inheriting it) so that "fatal: not a git repository"
28
+ // and similar messages from git invocations in non-repo paths don't leak to
29
+ // the parent terminal. Every caller wraps the runner in try/catch and discards
30
+ // errors; otherwise stderr bytes would print to the user's terminal regardless.
31
+ //
32
+ // The stdio override is applied AFTER spreading caller options so a caller
33
+ // cannot accidentally re-enable stderr inheritance by passing { stdio: 'inherit' }.
34
+ // Tests that need a custom runner should use _setGitRunner, not options.stdio.
35
+ function _runGit(args, options) {
36
+ return childProcess.execFileSync('git', args, { ...options, stdio: ['ignore', 'pipe', 'pipe'] });
37
+ }
38
+ let _gitRunner = _runGit;
27
39
 
28
40
  export function log(fields) {
29
41
  const entry = { ts: new Date().toISOString(), ...fields };
@@ -64,7 +76,7 @@ export function _clearGitCache() { GIT_ROOT_CACHE.clear(); }
64
76
  /** @internal Test helper: override the git runner. */
65
77
  export function _setGitRunner(fn) { _gitRunner = fn; }
66
78
  /** @internal Test helper: restore the default git runner. */
67
- export function _resetGitRunner() { _gitRunner = (args, options) => childProcess.execFileSync('git', args, options); }
79
+ export function _resetGitRunner() { _gitRunner = _runGit; }
68
80
 
69
81
  export const DEFAULT_SERVER_URL = 'http://localhost:3000';
70
82
 
@@ -74,6 +86,7 @@ export function ensureDataDir() {
74
86
  mkdirSync(SENDING_DIR, { recursive: true });
75
87
  mkdirSync(DEDUP_DIR, { recursive: true });
76
88
  mkdirSync(SESSION_PATHS_DIR, { recursive: true });
89
+ mkdirSync(TRANSCRIPT_OFFSETS_DIR, { recursive: true });
77
90
  mkdirSync(GIT_REMOTES_DIR, { recursive: true });
78
91
  }
79
92
 
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Shared token-usage helpers used by both Claude Code (capture.js) and
3
+ * Codex (codex.js) normalization paths. Extracted to keep the two in sync
4
+ * and avoid silent divergence between the two tracking pipelines.
5
+ */
6
+
7
+ /**
8
+ * Return `value` if it is a finite number, otherwise `null`.
9
+ * Used to guard JSON fields before copying them into raw payloads —
10
+ * protects against `NaN`, `undefined`, strings, etc. sneaking through.
11
+ */
12
+ export function toNumberOrNull(value) {
13
+ return typeof value === 'number' && Number.isFinite(value) ? value : null;
14
+ }
15
+
16
+ /**
17
+ * Lift Anthropic-style usage fields from `usage` onto a shallow clone of
18
+ * `event`, renaming cache keys to the short form used by the dashboard SQL:
19
+ *
20
+ * cache_read_input_tokens -> cache_read_tokens
21
+ * cache_creation_input_tokens -> cache_write_tokens
22
+ *
23
+ * Returns `event` unchanged if both `usage` and `model` are falsy, so callers
24
+ * can pass this through blindly without checking for presence first.
25
+ *
26
+ * Used by Cursor afterAgentResponse (where usage comes directly from the hook
27
+ * payload) and by Claude Code's per-call TokenUsage emission (where each call
28
+ * is one assistant line in the transcript). Codex has its own OpenAI-shaped
29
+ * helper because the field names differ.
30
+ *
31
+ * @param {object} event Base object to clone (the hook event for Cursor,
32
+ * a synthetic minimal carrier for Claude Code's
33
+ * per-call events).
34
+ * @param {object|null} usage Usage object with Anthropic-named keys.
35
+ * @param {string|null} model Model name from the assistant line.
36
+ */
37
+ export function buildTokenUsageRaw(event, usage, model) {
38
+ if (!usage && !model) return event;
39
+ return {
40
+ ...event,
41
+ ...(model ? { model } : {}),
42
+ ...(toNumberOrNull(usage?.input_tokens) != null ? { input_tokens: usage.input_tokens } : {}),
43
+ ...(toNumberOrNull(usage?.output_tokens) != null ? { output_tokens: usage.output_tokens } : {}),
44
+ ...(toNumberOrNull(usage?.cache_read_input_tokens) != null ? { cache_read_tokens: usage.cache_read_input_tokens } : {}),
45
+ ...(toNumberOrNull(usage?.cache_creation_input_tokens) != null ? { cache_write_tokens: usage.cache_creation_input_tokens } : {}),
46
+ };
47
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-lens",
3
- "version": "0.8.51",
3
+ "version": "0.8.52",
4
4
  "type": "module",
5
5
  "description": "Centralized session analytics for AI coding tools",
6
6
  "bin": {