From 56cc0d2e006a86093344d3d3259b5d805d25a715 Mon Sep 17 00:00:00 2001 From: adamhenson Date: Mon, 15 Jun 2026 10:48:37 -0400 Subject: [PATCH 01/11] fix(cli): bake AGENTMETER_API_URL into launchd plist and systemd unit Service was only writing AGENTMETER_API_KEY to environment variables, so a custom API URL (e.g. localhost) was lost after reboot. --- packages/cli/src/services/service-installer.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/services/service-installer.ts b/packages/cli/src/services/service-installer.ts index e6a0ad3..60f39b5 100644 --- a/packages/cli/src/services/service-installer.ts +++ b/packages/cli/src/services/service-installer.ts @@ -51,7 +51,7 @@ function escapeXml(str: string): string { /** * Generates the launchd plist XML content for the agentmeter sync service */ -function generatePlist(binaryPath: string, apiKey: string, logPath: string): string { +function generatePlist(binaryPath: string, config: Config, logPath: string): string { const nodePath = process.execPath; return ` @@ -76,7 +76,9 @@ function generatePlist(binaryPath: string, apiKey: string, logPath: string): str EnvironmentVariables AGENTMETER_API_KEY - ${escapeXml(apiKey)} + ${escapeXml(config.apiKey)} + AGENTMETER_API_URL + ${escapeXml(config.apiUrl)} @@ -86,7 +88,7 @@ function generatePlist(binaryPath: string, apiKey: string, logPath: string): str /** * Generates the systemd unit file content for the agentmeter sync service */ -function generateSystemdUnit(binaryPath: string, apiKey: string): string { +function generateSystemdUnit(binaryPath: string, config: Config): string { const nodePath = process.execPath; return `[Unit] Description=AgentMeter Session Sync @@ -96,7 +98,8 @@ Type=simple ExecStart=${nodePath} ${binaryPath} watch Restart=on-failure RestartSec=30 -Environment=AGENTMETER_API_KEY=${apiKey} +Environment=AGENTMETER_API_KEY=${config.apiKey} +Environment=AGENTMETER_API_URL=${config.apiUrl} [Install] WantedBy=default.target @@ -115,7 +118,7 @@ export function installMacos(config: Config): void { fs.mkdirSync(logDir, { recursive: true }); fs.mkdirSync(path.dirname(plistPath), { recursive: true }); - const plist = generatePlist(binaryPath, config.apiKey, logPath); + const plist = generatePlist(binaryPath, config, logPath); fs.writeFileSync(plistPath, plist, 'utf8'); // Unload first in case it was previously installed @@ -143,7 +146,7 @@ export function installLinux(config: Config): void { fs.mkdirSync(path.dirname(servicePath), { recursive: true }); - const unit = generateSystemdUnit(binaryPath, config.apiKey); + const unit = generateSystemdUnit(binaryPath, config); fs.writeFileSync(servicePath, unit, 'utf8'); spawnSync('systemctl', ['--user', 'daemon-reload']); From 6f61d97c0397dc9d04708b5a10d2a2ecb7082bf7 Mon Sep 17 00:00:00 2001 From: adamhenson Date: Mon, 15 Jun 2026 10:50:01 -0400 Subject: [PATCH 02/11] docs: add local testing guide for install/uninstall with localhost --- docs/testing-locally.md | 61 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 docs/testing-locally.md diff --git a/docs/testing-locally.md b/docs/testing-locally.md new file mode 100644 index 0000000..edea3e9 --- /dev/null +++ b/docs/testing-locally.md @@ -0,0 +1,61 @@ +# Testing the CLI locally against localhost + +Use these steps to test `install` / `uninstall` against a local AgentMeter server and verify the service survives reboots. + +## 1. Build and init + +```bash +pnpm build + +AGENTMETER_API_KEY= \ +AGENTMETER_API_URL=http://localhost:3000 \ +pnpm cli init +``` + +This writes both values to `~/.agentmeter/config.json`. + +## 2. Install the service + +```bash +pnpm cli install +``` + +This bakes `AGENTMETER_API_KEY` and `AGENTMETER_API_URL` into the launchd plist (macOS) or systemd unit (Linux) so the service is self-contained after reboot. + +## 3. Verify the plist looks right (macOS) + +```bash +cat ~/Library/LaunchAgents/com.agentmeter.sync.plist +``` + +Both env vars should appear in the `EnvironmentVariables` dict. + +## 4. Check it's running + +```bash +pnpm cli status +launchctl list com.agentmeter.sync +``` + +## 5. Watch the logs + +```bash +tail -f ~/.agentmeter/sync.log +``` + +## 6. Test reboot persistence + +Restart your machine. After login: + +```bash +launchctl list com.agentmeter.sync # should show a PID +tail ~/.agentmeter/sync.log # should show sync activity +``` + +> **Note:** `localhost:3000` won't be running automatically after reboot, so the service will log connection errors until your Next.js server is started. That's expected — it confirms the reboot persistence mechanism is working correctly. + +## Uninstall when done + +```bash +pnpm cli uninstall +``` From 4a6d3f70dbb1d6725a65579c54fc22506cfda311 Mon Sep 17 00:00:00 2001 From: adamhenson Date: Mon, 15 Jun 2026 11:04:31 -0400 Subject: [PATCH 03/11] fix(cli): use tsx runtime in launchd/systemd when running from TypeScript source When installed via pnpm cli (dev mode), process.argv[1] is a .ts file. Using bare node to run it causes exit code 1. Detect the .ts case and resolve the tsx binary so the service can actually start. --- .../cli/src/services/service-installer.ts | 48 ++++++++++++------- 1 file changed, 30 insertions(+), 18 deletions(-) diff --git a/packages/cli/src/services/service-installer.ts b/packages/cli/src/services/service-installer.ts index 60f39b5..d5ee134 100644 --- a/packages/cli/src/services/service-installer.ts +++ b/packages/cli/src/services/service-installer.ts @@ -24,16 +24,31 @@ function getSystemdServicePath(): string { } /** - * Resolves the installed agentmeter binary path, falling back to argv[1] + * Returns the program + arguments array for the service watch command. + * When running from TypeScript source (dev mode via tsx), uses the tsx binary + * as the program so launchd/systemd can execute the .ts file directly. */ -function findBinaryPath(): string { +function getServiceProgramArgs(): string[] { + const scriptPath = process.argv[1] ?? ''; + + if (scriptPath.endsWith('.ts')) { + // Dev mode: find tsx binary and use it as the runner + try { + const tsx = execFileSync('which', ['tsx'], { encoding: 'utf8' }).trim(); + if (tsx) return [tsx, scriptPath, 'watch']; + } catch { + // tsx not in PATH — fall through to production path + } + } + + // Production: agentmeter global binary, or fall back to argv[1] try { - const result = execFileSync('which', ['agentmeter'], { encoding: 'utf8' }).trim(); - if (result) return result; + const binary = execFileSync('which', ['agentmeter'], { encoding: 'utf8' }).trim(); + if (binary) return [process.execPath, binary, 'watch']; } catch { - // which failed — fall back to current script path + // not installed globally } - return process.argv[1] ?? 'agentmeter'; + return [process.execPath, scriptPath, 'watch']; } /** @@ -51,8 +66,8 @@ function escapeXml(str: string): string { /** * Generates the launchd plist XML content for the agentmeter sync service */ -function generatePlist(binaryPath: string, config: Config, logPath: string): string { - const nodePath = process.execPath; +function generatePlist(programArgs: string[], config: Config, logPath: string): string { + const argsXml = programArgs.map((a) => ` ${escapeXml(a)}`).join('\n'); return ` @@ -61,9 +76,7 @@ function generatePlist(binaryPath: string, config: Config, logPath: string): str ${escapeXml(LAUNCHD_LABEL)} ProgramArguments - ${escapeXml(nodePath)} - ${escapeXml(binaryPath)} - watch +${argsXml} RunAtLoad @@ -88,14 +101,13 @@ function generatePlist(binaryPath: string, config: Config, logPath: string): str /** * Generates the systemd unit file content for the agentmeter sync service */ -function generateSystemdUnit(binaryPath: string, config: Config): string { - const nodePath = process.execPath; +function generateSystemdUnit(programArgs: string[], config: Config): string { return `[Unit] Description=AgentMeter Session Sync [Service] Type=simple -ExecStart=${nodePath} ${binaryPath} watch +ExecStart=${programArgs.join(' ')} Restart=on-failure RestartSec=30 Environment=AGENTMETER_API_KEY=${config.apiKey} @@ -110,7 +122,7 @@ WantedBy=default.target * Installs and starts the agentmeter launchd service on macOS */ export function installMacos(config: Config): void { - const binaryPath = findBinaryPath(); + const programArgs = getServiceProgramArgs(); const logDir = getLogDir(); const logPath = getLogPath(); const plistPath = getLaunchdPlistPath(); @@ -118,7 +130,7 @@ export function installMacos(config: Config): void { fs.mkdirSync(logDir, { recursive: true }); fs.mkdirSync(path.dirname(plistPath), { recursive: true }); - const plist = generatePlist(binaryPath, config, logPath); + const plist = generatePlist(programArgs, config, logPath); fs.writeFileSync(plistPath, plist, 'utf8'); // Unload first in case it was previously installed @@ -141,12 +153,12 @@ export function uninstallMacos(): void { * Installs and starts the agentmeter systemd user service on Linux */ export function installLinux(config: Config): void { - const binaryPath = findBinaryPath(); + const programArgs = getServiceProgramArgs(); const servicePath = getSystemdServicePath(); fs.mkdirSync(path.dirname(servicePath), { recursive: true }); - const unit = generateSystemdUnit(binaryPath, config); + const unit = generateSystemdUnit(programArgs, config); fs.writeFileSync(servicePath, unit, 'utf8'); spawnSync('systemctl', ['--user', 'daemon-reload']); From 3d93744f4cd710c3f3b76e758abc3a05420533a1 Mon Sep 17 00:00:00 2001 From: adamhenson Date: Mon, 15 Jun 2026 11:17:30 -0400 Subject: [PATCH 04/11] fix(cli): add node bin dir to PATH in launchd plist launchd runs with a minimal PATH that omits /usr/local/bin, so the tsx shell wrapper could not exec node. Derive the node binary directory from process.execPath and prepend it to PATH in EnvironmentVariables. --- packages/cli/src/services/service-installer.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/services/service-installer.ts b/packages/cli/src/services/service-installer.ts index d5ee134..b4676a4 100644 --- a/packages/cli/src/services/service-installer.ts +++ b/packages/cli/src/services/service-installer.ts @@ -64,10 +64,14 @@ function escapeXml(str: string): string { } /** - * Generates the launchd plist XML content for the agentmeter sync service + * Generates the launchd plist XML content for the agentmeter sync service. + * Includes the node binary's directory in PATH so tsx shell wrappers can find node, + * since launchd runs with a minimal environment that omits /usr/local/bin. */ function generatePlist(programArgs: string[], config: Config, logPath: string): string { const argsXml = programArgs.map((a) => ` ${escapeXml(a)}`).join('\n'); + const nodeBinDir = path.dirname(process.execPath); + const servicePath = `${nodeBinDir}:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin`; return ` @@ -88,6 +92,8 @@ ${argsXml} ${escapeXml(logPath)} EnvironmentVariables + PATH + ${escapeXml(servicePath)} AGENTMETER_API_KEY ${escapeXml(config.apiKey)} AGENTMETER_API_URL From 36ff9b03b9ea42fe9f156eb5fc87cc2197b806a5 Mon Sep 17 00:00:00 2001 From: adamhenson Date: Mon, 15 Jun 2026 11:22:11 -0400 Subject: [PATCH 05/11] fix(cli): re-sync sessions when endTime advances Previously only status changes triggered re-submission. A session whose status stays 'success' throughout would keep stale token counts forever. Storing endTime in sync-state and re-syncing when it changes ensures the final token count is captured as the session grows and when it ends. --- packages/cli/src/commands/sync.ts | 6 +++++- packages/cli/src/schemas/sync-state.ts | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/commands/sync.ts b/packages/cli/src/commands/sync.ts index d5da584..14ceb76 100644 --- a/packages/cli/src/commands/sync.ts +++ b/packages/cli/src/commands/sync.ts @@ -66,7 +66,10 @@ function classifySessions(sessions: LocalSession[], syncState: SyncState): Sessi const existing = syncState.sessions[session.sessionId]; if (!existing) { toSync.push({ session, isNew: true }); - } else if (existing.status !== session.status) { + } else if ( + existing.status !== session.status || + existing.endTime !== (session.endTime ?? null) + ) { toSync.push({ session, isNew: false }); } else { skipped.push(session); @@ -139,6 +142,7 @@ async function submitAll( status: session.status, submittedAt: new Date().toISOString(), costCents: result.costCents ?? null, + endTime: session.endTime ?? null, }; if (result.costCents) totalCostCents += result.costCents; diff --git a/packages/cli/src/schemas/sync-state.ts b/packages/cli/src/schemas/sync-state.ts index d314c93..58fa777 100644 --- a/packages/cli/src/schemas/sync-state.ts +++ b/packages/cli/src/schemas/sync-state.ts @@ -4,6 +4,7 @@ export const SyncedSessionSchema = z.object({ status: z.enum(['success', 'failure', 'cancelled']), submittedAt: z.string(), costCents: z.number().int().nullable().optional(), + endTime: z.string().nullable().optional(), }); export const SyncStateSchema = z.object({ From 0c4814bda0582e5662d96e03ddb8734b2d379f03 Mon Sep 17 00:00:00 2001 From: adamhenson Date: Mon, 15 Jun 2026 11:24:43 -0400 Subject: [PATCH 06/11] feat(cli): use ai-title entry as session title Claude Code writes an ai-title entry to the JSONL containing the AI-generated tab title (e.g. 'Set up AgentMeter CLI re...'). Prefer that over the first-user-message heuristic, which was often noisy. --- packages/cli/src/scanners/claude.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/scanners/claude.ts b/packages/cli/src/scanners/claude.ts index 183886b..ee8d20a 100644 --- a/packages/cli/src/scanners/claude.ts +++ b/packages/cli/src/scanners/claude.ts @@ -37,6 +37,7 @@ const JournalEntrySchema = z uuid: z.string().optional(), timestamp: z.string().optional(), cwd: z.string().optional(), + aiTitle: z.string().optional(), message: MessageSchema.optional(), }) .passthrough(); @@ -81,11 +82,16 @@ function stripMarkdownHeading(text: string): string { } /** - * Finds the first meaningful user message to use as the session title. - * Skips entries whose content begins with an XML-style tag (e.g. ). - * Strips leading markdown heading syntax (e.g. # My Title → My Title). + * Extracts the session title, preferring Claude Code's AI-generated title + * (from ai-title entries) over the first meaningful user message. */ function extractTitle(entries: JournalEntry[]): string | null { + // Prefer the AI-generated title Claude Code writes to the JSONL + for (const entry of entries) { + if (entry.type === 'ai-title' && entry.aiTitle) return entry.aiTitle.slice(0, 120); + } + + // Fall back to first meaningful user message for (const entry of entries) { if (entry.type !== 'user' || !entry.message) continue; const content = entry.message.content; From 02f65eaabb0352621b6f17e292789834344bf227 Mon Sep 17 00:00:00 2001 From: adamhenson Date: Mon, 15 Jun 2026 12:05:50 -0400 Subject: [PATCH 07/11] feat(cli): emit 'running' status for sessions active within last 30 minutes Sessions whose last JSONL entry is within 30 minutes are likely still open. Using 'running' lets the dashboard show in-progress vs completed correctly, and triggers a re-sync on the next poll cycle to capture updated tokens. --- packages/cli/src/scanners/claude.ts | 11 ++++++++++- packages/cli/src/schemas/session.ts | 2 +- packages/cli/src/schemas/sync-state.ts | 2 +- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/scanners/claude.ts b/packages/cli/src/scanners/claude.ts index ee8d20a..cfdc833 100644 --- a/packages/cli/src/scanners/claude.ts +++ b/packages/cli/src/scanners/claude.ts @@ -205,10 +205,19 @@ function extractTiming(entries: JournalEntry[]): { return { startTime, endTime, durationSeconds }; } +const RUNNING_THRESHOLD_MS = 30 * 60 * 1000; // 30 minutes + /** - * Determines session status from the last assistant stop_reason + * Determines session status. Returns 'running' if the last entry timestamp is + * within 30 minutes — the session is likely still active. Otherwise uses the + * last assistant stop_reason to distinguish success from failure. */ function extractStatus(entries: JournalEntry[]): LocalSession['status'] { + const lastTimestamp = [...entries].reverse().find((e) => e.timestamp)?.timestamp; + if (lastTimestamp && Date.now() - new Date(lastTimestamp).getTime() < RUNNING_THRESHOLD_MS) { + return 'running'; + } + for (let i = entries.length - 1; i >= 0; i--) { const entry = entries[i]; if (entry?.type === 'assistant' && entry.message?.stop_reason) { diff --git a/packages/cli/src/schemas/session.ts b/packages/cli/src/schemas/session.ts index 0a20460..30d348a 100644 --- a/packages/cli/src/schemas/session.ts +++ b/packages/cli/src/schemas/session.ts @@ -12,7 +12,7 @@ export const LocalSessionSchema = z.object({ repoFullName: z.string(), engine: z.string(), model: z.string().nullable(), - status: z.enum(['success', 'failure', 'cancelled']), + status: z.enum(['running', 'success', 'failure', 'cancelled']), title: z.string().nullable(), startTime: z.string(), endTime: z.string().nullable(), diff --git a/packages/cli/src/schemas/sync-state.ts b/packages/cli/src/schemas/sync-state.ts index 58fa777..8b5facf 100644 --- a/packages/cli/src/schemas/sync-state.ts +++ b/packages/cli/src/schemas/sync-state.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; export const SyncedSessionSchema = z.object({ - status: z.enum(['success', 'failure', 'cancelled']), + status: z.enum(['running', 'success', 'failure', 'cancelled']), submittedAt: z.string(), costCents: z.number().int().nullable().optional(), endTime: z.string().nullable().optional(), From a97018332d82276ea30ddd576c45183af94fb421 Mon Sep 17 00:00:00 2001 From: adamhenson Date: Mon, 15 Jun 2026 13:48:57 -0400 Subject: [PATCH 08/11] docs(cli): apply jsdoc conventions to all functions, types, and interfaces Adds JSDoc blocks to every exported and internal function, type alias, and interface across packages/cli/src. Inline field docs added to all interface and object-type declarations per the updated typescript.md rule. --- .claude/rules/typescript.md | 25 +++++++++++++++++++++++- packages/cli/src/commands/init.ts | 3 +++ packages/cli/src/scanners/claude.ts | 1 + packages/cli/src/scanners/cursor.ts | 12 ++++++++++++ packages/cli/src/scanners/types.ts | 8 ++++++++ packages/cli/src/schemas/api-response.ts | 3 +++ packages/cli/src/schemas/config.ts | 1 + packages/cli/src/schemas/session.ts | 3 +++ packages/cli/src/services/api.ts | 20 +++++++++++++++++++ packages/cli/src/services/logger.ts | 1 + packages/cli/src/utils/platform.ts | 1 + packages/cli/src/utils/retry.ts | 8 ++++++++ 12 files changed, 85 insertions(+), 1 deletion(-) diff --git a/.claude/rules/typescript.md b/.claude/rules/typescript.md index bbf9a92..333a90d 100644 --- a/.claude/rules/typescript.md +++ b/.claude/rules/typescript.md @@ -13,7 +13,7 @@ description: TypeScript and React code style conventions ## JSDoc -JSDoc block above every function/component — no `@param`/`@returns` tags; TypeScript is the source of truth. Document each param inline in the type. +JSDoc block above every function/component — no `@param`/`@returns` tags; TypeScript is the source of truth. Document each param inline in the type. Also do the same for `interface` and `type` declarations. ```typescript /** @@ -25,7 +25,30 @@ const myFunc = ({ }: { /** What bar represents */ bar: number; + /** What foo represents */ foo?: string; }): void => {}; + +/** + * Represents an animal of the feline species + */ +interface Cat { + /** The color of the fur **/ + color: string; + + /** The weight in pounds **/ + weight: number; +} + +/** + * Represents an animal within our system + */ +type Animal = { + /** The id of the animal in our system **/ + id: string; + + /** The type of animal **/ + type: Cat | Dog; +}; ``` diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index f3d5d75..4a12a19 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -6,6 +6,9 @@ import pc from 'picocolors'; import { ApiClient } from '../services/api.js'; import { writeConfig } from '../services/config.js'; +/** + * Returns a truncated preview of an API key safe for display in terminal output + */ function maskKey(key: string): string { if (key.length <= 8) return `${key.slice(0, 4)}...`; return `${key.slice(0, 8)}...`; diff --git a/packages/cli/src/scanners/claude.ts b/packages/cli/src/scanners/claude.ts index cfdc833..1ccf103 100644 --- a/packages/cli/src/scanners/claude.ts +++ b/packages/cli/src/scanners/claude.ts @@ -42,6 +42,7 @@ const JournalEntrySchema = z }) .passthrough(); +/** A single line parsed from a Claude Code JSONL session file */ type JournalEntry = z.infer; /** diff --git a/packages/cli/src/scanners/cursor.ts b/packages/cli/src/scanners/cursor.ts index 6bb3c7b..211d9f0 100644 --- a/packages/cli/src/scanners/cursor.ts +++ b/packages/cli/src/scanners/cursor.ts @@ -5,6 +5,9 @@ import type { LocalSession } from '../schemas/session.js'; import { logger } from '../services/logger.js'; import type { SessionScanner } from './types.js'; +/** + * Returns the platform-specific path to Cursor's application data directory + */ function getCursorDataDir(): string { if (process.platform === 'darwin') { return path.join(os.homedir(), 'Library', 'Application Support', 'Cursor'); @@ -15,9 +18,15 @@ function getCursorDataDir(): string { return path.join(os.homedir(), 'AppData', 'Roaming', 'Cursor'); } +/** + * Scanner for Cursor AI coding agent sessions (support coming soon) + */ export class CursorScanner implements SessionScanner { readonly name = 'cursor'; + /** + * Returns true if the Cursor application data directory exists on this machine + */ async isAvailable(): Promise { try { return fs.statSync(getCursorDataDir()).isDirectory(); @@ -26,6 +35,9 @@ export class CursorScanner implements SessionScanner { } } + /** + * Placeholder — always returns an empty array until Cursor support is implemented + */ async scan(): Promise { logger.info('Cursor support coming soon — skipping'); return []; diff --git a/packages/cli/src/scanners/types.ts b/packages/cli/src/scanners/types.ts index e971d77..3c9cb7c 100644 --- a/packages/cli/src/scanners/types.ts +++ b/packages/cli/src/scanners/types.ts @@ -1,7 +1,15 @@ export type { LocalSession } from '../schemas/session.js'; +/** + * Common contract for all AI coding agent session scanners + */ export interface SessionScanner { + /** Human-readable scanner identifier (e.g. "claude", "cursor") */ readonly name: string; + + /** Returns true if the scanner's data directory exists on this machine */ isAvailable(): Promise; + + /** Scans all available sessions and returns them as normalized LocalSession objects */ scan(): Promise; } diff --git a/packages/cli/src/schemas/api-response.ts b/packages/cli/src/schemas/api-response.ts index 43fdf97..bde1a4a 100644 --- a/packages/cli/src/schemas/api-response.ts +++ b/packages/cli/src/schemas/api-response.ts @@ -12,5 +12,8 @@ export const ValidateKeyResponseSchema = z.object({ keyType: z.enum(['personal', 'org']).optional(), }); +/** Successful API response from POST /api/ingest/local */ export type ApiSuccessResponse = z.infer; + +/** Response from GET /api/auth/me key validation endpoint */ export type ValidateKeyResponse = z.infer; diff --git a/packages/cli/src/schemas/config.ts b/packages/cli/src/schemas/config.ts index 617c402..02b2089 100644 --- a/packages/cli/src/schemas/config.ts +++ b/packages/cli/src/schemas/config.ts @@ -6,4 +6,5 @@ export const ConfigSchema = z.object({ apiUrl: z.string().url().default('https://agentmeter.app'), }); +/** Validated CLI configuration stored in ~/.agentmeter/config.json */ export type Config = z.infer; diff --git a/packages/cli/src/schemas/session.ts b/packages/cli/src/schemas/session.ts index 30d348a..7809e27 100644 --- a/packages/cli/src/schemas/session.ts +++ b/packages/cli/src/schemas/session.ts @@ -20,5 +20,8 @@ export const LocalSessionSchema = z.object({ tokens: TokensSchema, }); +/** Aggregated token counts for a session */ export type Tokens = z.infer; + +/** Normalized session record produced by a scanner and submitted to the API */ export type LocalSession = z.infer; diff --git a/packages/cli/src/services/api.ts b/packages/cli/src/services/api.ts index 095de55..41aa12b 100644 --- a/packages/cli/src/services/api.ts +++ b/packages/cli/src/services/api.ts @@ -4,17 +4,37 @@ import type { LocalSession } from '../schemas/session.js'; import { withRetry } from '../utils/retry.js'; import { logger } from './logger.js'; +/** + * Outcome of submitting a single session to POST /api/ingest/local + */ export interface SubmitResult { + /** The session ID that was submitted */ sessionId: string; + + /** Cost in cents returned by the API, or null if not yet calculated */ costCents: number | null; + + /** Whether the session was newly created, updated, a duplicate, or failed */ status: 'created' | 'updated' | 'duplicate' | 'error'; + + /** Error message when status is 'error' */ error?: string; } +/** + * Outcome of validating an API key against GET /api/auth/me + */ export interface ValidateKeyResult { + /** Whether the key was accepted by the API */ valid: boolean; + + /** Organization name associated with the key, or null */ orgName: string | null; + + /** User display name associated with the key, or null */ userName: string | null; + + /** Whether the key is scoped to a personal user or an org, or null if unknown */ keyType: 'personal' | 'org' | null; } diff --git a/packages/cli/src/services/logger.ts b/packages/cli/src/services/logger.ts index 40dd6b4..44b7f8c 100644 --- a/packages/cli/src/services/logger.ts +++ b/packages/cli/src/services/logger.ts @@ -1,6 +1,7 @@ import fs from 'node:fs'; import { getLogDir, getLogPath } from '../utils/platform.js'; +/** Severity levels for log entries */ type LogLevel = 'info' | 'warn' | 'error'; const MAX_LOG_SIZE = 10 * 1024 * 1024; diff --git a/packages/cli/src/utils/platform.ts b/packages/cli/src/utils/platform.ts index 9568a3e..8339ac2 100644 --- a/packages/cli/src/utils/platform.ts +++ b/packages/cli/src/utils/platform.ts @@ -50,6 +50,7 @@ export function getClaudeProjectsDir(): string { return path.join(getHomeDir(), '.claude', 'projects'); } +/** Normalized platform identifier returned by getPlatform */ export type Platform = 'macos' | 'linux' | 'windows' | 'unsupported'; /** diff --git a/packages/cli/src/utils/retry.ts b/packages/cli/src/utils/retry.ts index 21fe462..3d11389 100644 --- a/packages/cli/src/utils/retry.ts +++ b/packages/cli/src/utils/retry.ts @@ -1,6 +1,14 @@ +/** + * Configuration for exponential-backoff retry behavior + */ export interface RetryOptions { + /** Maximum number of attempts before throwing the last error */ maxAttempts: number; + + /** Initial delay in milliseconds before the first retry */ baseDelayMs: number; + + /** Upper bound on the computed delay between retries */ maxDelayMs: number; } From 2d02f33e6807fe2a1499c021a8264e2d1efb967d Mon Sep 17 00:00:00 2001 From: adamhenson Date: Mon, 15 Jun 2026 13:49:08 -0400 Subject: [PATCH 09/11] fix(cli): re-sync sessions when title changes Adds 'title' to SyncedSession so the persisted sync state tracks what title was last submitted. classifySessions now treats a title change as a re-sync trigger alongside status and endTime changes. This fixes the drift where a session synced before Claude Code writes its ai-title entry would keep the user-message-derived title in the dashboard forever. --- packages/cli/src/commands/sync.ts | 32 +++++++++++++++++++++++++- packages/cli/src/schemas/sync-state.ts | 4 ++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/commands/sync.ts b/packages/cli/src/commands/sync.ts index 14ceb76..ca36f35 100644 --- a/packages/cli/src/commands/sync.ts +++ b/packages/cli/src/commands/sync.ts @@ -11,23 +11,51 @@ import { logger } from '../services/logger.js'; import { readSyncState, writeSyncState } from '../services/sync-state.js'; import { formatCost, formatDuration } from '../utils/format.js'; +/** + * Runtime options for a sync run, controlling output and filtering + */ export interface SyncOptions { + /** Whether to print a row for every session processed */ verbose: boolean; + + /** When true, reports what would be submitted without sending any data */ dryRun: boolean; + + /** ISO 8601 date string; only sessions starting on or after this date are included */ since?: string; + + /** Scanner name filter (e.g. "claude"); omit to include all available scanners */ engine?: string; } +/** + * Aggregated counts and cost totals returned from a completed sync run + */ export interface SyncResult { + /** Number of sessions submitted for the first time */ newCount: number; + + /** Number of sessions re-submitted because their status or endTime changed */ updatedCount: number; + + /** Number of sessions that were already up-to-date and skipped */ skippedCount: number; + + /** Number of sessions that failed to submit */ errorCount: number; + + /** Sum of costCents across all successfully submitted sessions */ totalCostCents: number; } +/** + * Sessions split into those needing submission and those already up-to-date + */ interface SessionClassification { + /** Sessions to submit, tagged with whether they are new or just updated */ toSync: Array<{ session: LocalSession; isNew: boolean }>; + + /** Sessions that match their already-synced state and can be skipped */ skipped: LocalSession[]; } @@ -68,7 +96,8 @@ function classifySessions(sessions: LocalSession[], syncState: SyncState): Sessi toSync.push({ session, isNew: true }); } else if ( existing.status !== session.status || - existing.endTime !== (session.endTime ?? null) + existing.endTime !== (session.endTime ?? null) || + (existing.title ?? null) !== (session.title ?? null) ) { toSync.push({ session, isNew: false }); } else { @@ -143,6 +172,7 @@ async function submitAll( submittedAt: new Date().toISOString(), costCents: result.costCents ?? null, endTime: session.endTime ?? null, + title: session.title ?? null, }; if (result.costCents) totalCostCents += result.costCents; diff --git a/packages/cli/src/schemas/sync-state.ts b/packages/cli/src/schemas/sync-state.ts index 8b5facf..40145a9 100644 --- a/packages/cli/src/schemas/sync-state.ts +++ b/packages/cli/src/schemas/sync-state.ts @@ -5,6 +5,7 @@ export const SyncedSessionSchema = z.object({ submittedAt: z.string(), costCents: z.number().int().nullable().optional(), endTime: z.string().nullable().optional(), + title: z.string().nullable().optional(), }); export const SyncStateSchema = z.object({ @@ -12,5 +13,8 @@ export const SyncStateSchema = z.object({ sessions: z.record(z.string(), SyncedSessionSchema).default({}), }); +/** Persisted record of a session that has been submitted to the API, used to detect re-sync-worthy changes */ export type SyncedSession = z.infer; + +/** Contents of the ~/.agentmeter/sync-state.json file */ export type SyncState = z.infer; From bcb1a158a216440d18de637abd9b4886bd2ec3bd Mon Sep 17 00:00:00 2001 From: adamhenson Date: Mon, 15 Jun 2026 14:08:14 -0400 Subject: [PATCH 10/11] fix(cli): use last ai-title entry rather than first Claude Code rewrites the ai-title entry multiple times as the conversation progresses, with the final value being most accurate. Previously extractTitle returned the first match, causing stale early titles to win over the refined later ones. --- packages/cli/src/scanners/claude.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/scanners/claude.ts b/packages/cli/src/scanners/claude.ts index 1ccf103..1f6d9fb 100644 --- a/packages/cli/src/scanners/claude.ts +++ b/packages/cli/src/scanners/claude.ts @@ -87,9 +87,12 @@ function stripMarkdownHeading(text: string): string { * (from ai-title entries) over the first meaningful user message. */ function extractTitle(entries: JournalEntry[]): string | null { - // Prefer the AI-generated title Claude Code writes to the JSONL - for (const entry of entries) { - if (entry.type === 'ai-title' && entry.aiTitle) return entry.aiTitle.slice(0, 120); + // Prefer the AI-generated title Claude Code writes to the JSONL. + // Iterate in reverse — Claude Code rewrites the title as the conversation progresses, + // so the last ai-title entry is the most accurate. + for (let i = entries.length - 1; i >= 0; i--) { + const entry = entries[i]; + if (entry?.type === 'ai-title' && entry.aiTitle) return entry.aiTitle.slice(0, 120); } // Fall back to first meaningful user message From 1ce6327e05dbc9f58a4891f83b3d43e2590e47ff Mon Sep 17 00:00:00 2001 From: adamhenson Date: Mon, 15 Jun 2026 18:36:22 -0400 Subject: [PATCH 11/11] feat(cli): add Cursor scanner with multi-format token extraction Reads Cursor agent sessions from the global state DB and extracts token counts across three storage formats that Cursor has used across versions: - agentKv:bubbleCheckpoint:* (protobuf, mid-era): batch query all checkpoints, decode protobuf blobs to get context tokens and model name - bubbleId:* JSON tokenCount (older era): per-session BETWEEN range scan using B-tree index (LIKE queries hit ~148K rows each and take ~1s/session) - composerData.contextTokensUsed (newest era): final context-window size used as fallback when neither earlier format has data All Cursor token counts are marked isApproximate: true since Cursor is subscription-based and doesn't expose exact API billing data locally. Adds isApproximate field to TokensSchema and threads it through api.ts. --- packages/cli/__tests__/commands/sync.test.ts | 14 + packages/cli/src/scanners/cursor.ts | 556 ++++++++++++++++++- packages/cli/src/schemas/session.ts | 2 + packages/cli/src/services/api.ts | 2 +- packages/cli/tsup.config.ts | 5 +- 5 files changed, 572 insertions(+), 7 deletions(-) diff --git a/packages/cli/__tests__/commands/sync.test.ts b/packages/cli/__tests__/commands/sync.test.ts index 7864efb..66e2e1c 100644 --- a/packages/cli/__tests__/commands/sync.test.ts +++ b/packages/cli/__tests__/commands/sync.test.ts @@ -15,6 +15,20 @@ vi.mock('../../src/utils/platform.js', () => ({ getPlatform: () => 'macos', })); +// Prevent the Cursor scanner from hitting real system files in tests — +// it would scan the developer's actual Cursor data dir if not mocked. +vi.mock('../../src/scanners/cursor.js', () => ({ + CursorScanner: class { + readonly name = 'cursor'; + async isAvailable() { + return false; + } + async scan() { + return []; + } + }, +})); + vi.mock('../../src/services/logger.js', () => ({ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, setForegroundMode: vi.fn(), diff --git a/packages/cli/src/scanners/cursor.ts b/packages/cli/src/scanners/cursor.ts index 211d9f0..abaa5ea 100644 --- a/packages/cli/src/scanners/cursor.ts +++ b/packages/cli/src/scanners/cursor.ts @@ -1,12 +1,215 @@ import fs from 'node:fs'; +import { createRequire } from 'node:module'; import os from 'node:os'; import path from 'node:path'; +// Type-only import: erased at compile time so Vite/esbuild never statically +// resolves node:sqlite. The actual class is loaded at runtime via createRequire +// (a CJS-style require that bypasses esbuild's static import analysis). +import type { DatabaseSync } from 'node:sqlite'; +import { z } from 'zod'; import type { LocalSession } from '../schemas/session.js'; import { logger } from '../services/logger.js'; +import { resolveRepoFullName } from '../utils/repo.js'; import type { SessionScanner } from './types.js'; +// createRequire lets us load node:sqlite at runtime without esbuild stripping +// the node: prefix (which it does for ESM dynamic imports of experimental modules). +const _require = createRequire(import.meta.url); + +// ─── Zod schemas ─────────────────────────────────────────────────────────── + +const WorkspaceUriSchema = z + .object({ + /** Absolute filesystem path to the workspace folder */ + fsPath: z.string().optional(), + /** Workspace folder path (may differ from fsPath on non-macOS) */ + path: z.string().optional(), + /** Full file URI, e.g. "file:///Users/adam/Projects/my-app" */ + external: z.string().optional(), + }) + .passthrough(); + +const WorkspaceIdentifierSchema = z + .object({ + uri: WorkspaceUriSchema.optional(), + }) + .passthrough(); + +const ComposerHeaderSchema = z + .object({ + /** UUID that links to agentKv:bubbleCheckpoint:{composerId}:* keys */ + composerId: z.string(), + /** Unix milliseconds */ + createdAt: z.number().optional(), + /** Unix milliseconds */ + lastUpdatedAt: z.number().optional(), + /** AI-generated session title */ + name: z.string().optional(), + /** Session interaction mode */ + unifiedMode: z.enum(['chat', 'agent', 'plan']).catch('chat').optional(), + /** Whether the session was archived by the user */ + isArchived: z.boolean().optional(), + workspaceIdentifier: WorkspaceIdentifierSchema.optional(), + }) + .passthrough(); + +const ComposerHeadersSchema = z + .object({ + allComposers: z.array(ComposerHeaderSchema).optional(), + }) + .passthrough(); + +/** Decoded data extracted from a single bubble checkpoint protobuf blob */ +interface BubbleData { + modelName: string | null; + contextTokens: number | null; +} + +// ─── Minimal protobuf decoder ────────────────────────────────────────────── + +/** + * Reads a protobuf varint from buf starting at pos. + * Returns [value, nextPos]. Safe for values up to 2^53. + */ +function readVarint(buf: Buffer, pos: number): [value: number, nextPos: number] { + let result = 0; + let multiplier = 1; + let offset = pos; + while (offset < buf.length) { + const byte = buf[offset]; + if (byte === undefined) break; + offset++; + result += (byte & 0x7f) * multiplier; + multiplier *= 128; + if ((byte & 0x80) === 0) break; + } + return [result, offset]; +} + +/** + * Extracts the modelName from a Cursor assistant message JSON blob (field 4). + * The JSON has shape: { role, content: [{ providerOptions: { cursor: { modelName } } }] } + */ +function extractModelFromJson(jsonStr: string): string | null { + let parsed: unknown; + try { + parsed = JSON.parse(jsonStr); + } catch { + return null; + } + if (!parsed || typeof parsed !== 'object') return null; + const p = parsed as Record; + const content = p.content; + if (!Array.isArray(content)) return null; + for (const item of content) { + if (!item || typeof item !== 'object') continue; + const opts = (item as Record).providerOptions; + if (!opts || typeof opts !== 'object') continue; + const cursor = (opts as Record).cursor; + if (!cursor || typeof cursor !== 'object') continue; + const mn = (cursor as Record).modelName; + if (typeof mn === 'string' && mn.length > 0) return mn; + } + return null; +} + +/** + * Decodes a bubble checkpoint protobuf hex blob to extract the model name + * (from field 4 JSON) and cumulative context token count (from field 5, sub-field 1). + * Gracefully returns nulls for any field that can't be extracted. + */ +function decodeBubbleCheckpoint(hex: string): BubbleData { + let buf: Buffer; + try { + buf = Buffer.from(hex, 'hex'); + } catch { + return { modelName: null, contextTokens: null }; + } + + let modelName: string | null = null; + let contextTokens: number | null = null; + let offset = 0; + + while (offset < buf.length) { + let tag: number; + [tag, offset] = readVarint(buf, offset); + if (offset >= buf.length && tag === 0) break; + + const fieldNumber = tag >>> 3; + const wireType = tag & 0x7; + + if (wireType === 0) { + [, offset] = readVarint(buf, offset); + } else if (wireType === 2) { + const [len, dataStart] = readVarint(buf, offset); + const dataEnd = dataStart + len; + + if (fieldNumber === 4 && modelName === null) { + const jsonStr = buf.toString('utf8', dataStart, dataEnd); + modelName = extractModelFromJson(jsonStr); + } else if (fieldNumber === 5 && contextTokens === null) { + const nested = buf.subarray(dataStart, dataEnd); + let nOffset = 0; + while (nOffset < nested.length) { + let nTag: number; + [nTag, nOffset] = readVarint(nested, nOffset); + const nField = nTag >>> 3; + const nWire = nTag & 0x7; + if (nWire === 0) { + let val: number; + [val, nOffset] = readVarint(nested, nOffset); + if (nField === 1) { + contextTokens = val; + break; + } + } else { + break; + } + } + } + + offset = dataEnd; + } else if (wireType === 1) { + offset += 8; + } else if (wireType === 5) { + offset += 4; + } else { + break; + } + } + + return { modelName, contextTokens }; +} + +// ─── Model name normalization ────────────────────────────────────────────── + +/** + * Normalizes a Cursor model name to the AgentMeter pricing matrix key format. + * + * Cursor uses `claude-{version}-{family}[-{variant}]` (e.g. "claude-4.5-sonnet-thinking"), + * while the AgentMeter matrix uses `claude-{family}-{version}` (e.g. "claude-sonnet-4-5"). + * GPT models may have trailing quality qualifiers (-high/-medium/-low) that are stripped. + */ +function normalizeCursorModel(raw: string): string { + const claudeMatch = raw.match(/^claude-(\d+\.\d+)-(sonnet|haiku|opus)/i); + if (claudeMatch) { + const version = claudeMatch[1]?.replace('.', '-') ?? ''; + const family = claudeMatch[2]?.toLowerCase() ?? ''; + return `claude-${family}-${version}`; + } + if (raw.startsWith('gpt-')) { + return raw.replace(/-(high|medium|low)$/, ''); + } + return raw; +} + +// ─── Platform paths ──────────────────────────────────────────────────────── + /** - * Returns the platform-specific path to Cursor's application data directory + * Returns the platform-specific path to Cursor's application data directory. + * macOS: ~/Library/Application Support/Cursor + * Linux: ~/.config/Cursor + * Windows: ~/AppData/Roaming/Cursor */ function getCursorDataDir(): string { if (process.platform === 'darwin') { @@ -19,7 +222,241 @@ function getCursorDataDir(): string { } /** - * Scanner for Cursor AI coding agent sessions (support coming soon) + * Returns the path to Cursor's global state DB. + * This DB holds both session headers (composer.composerHeaders in ItemTable) + * and all bubble checkpoint blobs (agentKv:* in cursorDiskKV). + */ +function getGlobalDbPath(): string { + return path.join(getCursorDataDir(), 'User', 'globalStorage', 'state.vscdb'); +} + +// ─── Database helpers ────────────────────────────────────────────────────── + +/** + * Opens a SQLite DB at the given path. Returns null if the file doesn't exist + * or cannot be opened (e.g. locked by Cursor). + */ +function openDb(DbClass: typeof DatabaseSync, dbPath: string): DatabaseSync | null { + try { + return new DbClass(dbPath); + } catch { + return null; + } +} + +/** + * Reads a single text value from ItemTable by key. Returns null if missing or on error. + */ +function readItemTableValue(db: DatabaseSync, key: string): string | null { + try { + const stmt = db.prepare('SELECT value FROM ItemTable WHERE key = ?'); + const row = stmt.get(key) as Record | undefined; + const val = row?.value; + return typeof val === 'string' ? val : null; + } catch { + return null; + } +} + +/** + * Reads ALL bubble checkpoint hashes from cursorDiskKV in a single query, + * returning a Map from composerId to the list of SHA256 hash strings for that session. + * + * Key format: agentKv:bubbleCheckpoint:{composerId}:{bubbleId} → SHA256 hash + * Per-session LIKE queries are avoided because cursorDiskKV has ~148K rows and the + * index is not used efficiently for LIKE, making per-session scans ~1s each (230s total). + * One batch query returning all ~305 rows takes ~2s regardless of session count. + */ +function readAllBubbleHashesBySession(db: DatabaseSync): Map { + const result = new Map(); + try { + const rows = db + .prepare("SELECT key, value FROM cursorDiskKV WHERE key LIKE 'agentKv:bubbleCheckpoint:%'") + .all() as Record[]; + for (const row of rows) { + const key = row.key; + const val = row.value; + if (typeof key !== 'string' || typeof val !== 'string' || val.length === 0) continue; + // key format: agentKv:bubbleCheckpoint:{composerId}:{bubbleId} + const parts = key.split(':'); + const composerId = parts[2]; + if (!composerId) continue; + const existing = result.get(composerId); + if (existing) { + existing.push(val); + } else { + result.set(composerId, [val]); + } + } + } catch { + // Return empty map — caller will skip sessions with no hashes + } + return result; +} + +/** + * Reads token totals for a single session from the older `bubbleId:{composerId}:{bubbleId}` + * storage format. Uses a BETWEEN range scan (O(log n)) rather than LIKE (O(n table scan)). + * Returns null when the session has no token data in this format. + */ +function readBubbleIdTokens( + stmt: ReturnType, + composerId: string, +): { input: number; output: number } | null { + try { + const lo = `bubbleId:${composerId}:`; + const hi = `bubbleId:${composerId}:￿`; + const row = stmt.get(lo, hi) as Record | undefined; + const input = typeof row?.totalInput === 'number' ? row.totalInput : 0; + const output = typeof row?.totalOutput === 'number' ? row.totalOutput : 0; + return input > 0 || output > 0 ? { input, output } : null; + } catch { + return null; + } +} + +/** + * Fetches the hex-encoded protobuf blobs for all content hashes in a single batch query. + * Returns a Map from hash to blob hex string. + */ +function readAllBlobs(db: DatabaseSync, hashes: string[]): Map { + const result = new Map(); + if (hashes.length === 0) return result; + try { + const placeholders = hashes.map(() => '?').join(', '); + const keys = hashes.map((h) => `agentKv:blob:${h}`); + const rows = db + .prepare(`SELECT key, value FROM cursorDiskKV WHERE key IN (${placeholders})`) + .all(...keys) as Record[]; + for (const row of rows) { + const key = row.key; + const val = row.value; + if (typeof key !== 'string' || typeof val !== 'string' || val.length === 0) continue; + const hash = key.slice('agentKv:blob:'.length); + result.set(hash, val); + } + } catch { + // Return partial result — caller will skip sessions with no blobs + } + return result; +} + +/** + * Reads the context-window token count from `composerData:{composerId}` in cursorDiskKV. + * This is the final context-window size at the end of the session — used as a fallback + * when neither the agentKv protobuf nor the older bubbleId JSON format has data. + * Returns null if the entry is missing or contextTokensUsed is zero. + */ +function readComposerDataTokens(db: DatabaseSync, composerId: string): number | null { + try { + const row = db + .prepare('SELECT value FROM cursorDiskKV WHERE key = ?') + .get(`composerData:${composerId}`) as Record | undefined; + if (!row) return null; + const val = row.value; + if (typeof val !== 'string') return null; + const parsed: unknown = JSON.parse(val); + if (!parsed || typeof parsed !== 'object') return null; + const tokens = (parsed as Record).contextTokensUsed; + return typeof tokens === 'number' && tokens > 0 ? tokens : null; + } catch { + return null; + } +} + +// ─── Session building ────────────────────────────────────────────────────── + +const RUNNING_THRESHOLD_MS = 30 * 60 * 1000; + +/** + * Aggregates token counts and picks the first non-null model name + * from all decoded bubble checkpoint blobs for a session. + */ +function aggregateBubbles(blobs: string[]): { totalTokens: number; model: string | null } { + let totalTokens = 0; + let model: string | null = null; + for (const hex of blobs) { + const { modelName, contextTokens } = decodeBubbleCheckpoint(hex); + if (contextTokens !== null) totalTokens += contextTokens; + if (modelName !== null && model === null) model = modelName; + } + return { totalTokens, model }; +} + +/** + * Converts a file:// URI to an absolute local path, or returns the input unchanged. + */ +function fileUriToPath(uri: string): string { + if (uri.startsWith('file://')) return uri.slice('file://'.length); + return uri; +} + +/** + * Extracts the best available workspace path from a WorkspaceIdentifier URI. + */ +function resolveWorkspacePath(wsId: z.infer | undefined): string { + if (!wsId?.uri) return ''; + const uri = wsId.uri; + if (uri.fsPath) return uri.fsPath; + if (uri.path) return uri.path; + if (uri.external) return fileUriToPath(uri.external); + return ''; +} + +/** + * Builds a LocalSession from a Cursor composer header and its aggregated bubble data. + */ +function buildCursorSession({ + composerId, + title, + createdAt, + lastUpdatedAt, + workspacePath, + totalTokens, + rawModel, +}: { + composerId: string; + title: string | null; + createdAt: number; + lastUpdatedAt: number; + workspacePath: string; + totalTokens: number; + rawModel: string | null; +}): LocalSession { + const startTime = new Date(createdAt).toISOString(); + const endTime = new Date(lastUpdatedAt).toISOString(); + const durationSeconds = Math.max(0, Math.round((lastUpdatedAt - createdAt) / 1000)); + const isRunning = Date.now() - lastUpdatedAt < RUNNING_THRESHOLD_MS; + + return { + sessionId: composerId, + repoFullName: workspacePath ? resolveRepoFullName(workspacePath) : 'unknown', + engine: 'cursor', + model: rawModel !== null ? normalizeCursorModel(rawModel) : null, + status: isRunning ? 'running' : 'success', + title, + startTime, + endTime, + durationSeconds, + tokens: { + input: totalTokens, + output: 0, + cacheRead: 0, + cacheWrite: 0, + isApproximate: true, + }, + }; +} + +// ─── Scanner ─────────────────────────────────────────────────────────────── + +/** + * Scanner for Cursor AI coding agent sessions (agent mode only). + * + * Reads session metadata from `composer.composerHeaders` in the global state DB + * and token/model data from protobuf bubble checkpoint blobs in cursorDiskKV. + * Only sessions that have per-turn bubble checkpoints (agent-mode sessions with + * local token data) are reported; chat-only sessions have no local token records. */ export class CursorScanner implements SessionScanner { readonly name = 'cursor'; @@ -36,10 +473,119 @@ export class CursorScanner implements SessionScanner { } /** - * Placeholder — always returns an empty array until Cursor support is implemented + * Scans Cursor agent sessions and returns them as normalized LocalSession objects. + * Token counts are summed from per-turn protobuf bubble checkpoints (approximate). + * Sessions with no bubble checkpoint data are skipped. */ async scan(): Promise { - logger.info('Cursor support coming soon — skipping'); - return []; + // Load node:sqlite at runtime via createRequire. This avoids the esbuild + // issue where dynamic import('node:sqlite') has the node: prefix stripped. + let DatabaseSync: typeof import('node:sqlite').DatabaseSync; + try { + const sqlite = _require('node:sqlite') as typeof import('node:sqlite'); + DatabaseSync = sqlite.DatabaseSync; + } catch { + logger.warn('node:sqlite not available — Cursor scanning disabled'); + return []; + } + + const globalDb = openDb(DatabaseSync, getGlobalDbPath()); + if (!globalDb) { + logger.warn('Cursor global DB unavailable — skipping Cursor scan'); + return []; + } + + try { + return this._scanWithDb(globalDb); + } finally { + globalDb.close(); + } + } + + /** + * Internal scan implementation that operates on an open global DB connection. + * + * Cursor uses two different storage schemes depending on its version: + * - Newer: agentKv:bubbleCheckpoint:{composerId}:{bubbleId} → SHA256 → protobuf blob + * - Older: bubbleId:{composerId}:{bubbleId} → JSON with tokenCount field + * + * We batch the newer format into 2 queries, then fall back to per-session BETWEEN + * range scans for the older format (BETWEEN uses the B-tree index; LIKE does not). + */ + private _scanWithDb(globalDb: DatabaseSync): LocalSession[] { + const headersRaw = readItemTableValue(globalDb, 'composer.composerHeaders'); + if (!headersRaw) return []; + + let parsedHeaders: z.infer; + try { + const result = ComposerHeadersSchema.safeParse(JSON.parse(headersRaw)); + if (!result.success) return []; + parsedHeaders = result.data; + } catch { + return []; + } + + // Newer format: batch load all agentKv bubble checkpoints and blobs. + const hashesBySession = readAllBubbleHashesBySession(globalDb); + const allHashes = [...hashesBySession.values()].flat(); + const blobByHash = readAllBlobs(globalDb, allHashes); + + // Older format: prepared statement for per-session BETWEEN range scan. + const bubbleIdStmt = globalDb.prepare(` + SELECT + SUM(COALESCE(json_extract(value, '$.tokenCount.inputTokens'), 0)) AS totalInput, + SUM(COALESCE(json_extract(value, '$.tokenCount.outputTokens'), 0)) AS totalOutput + FROM cursorDiskKV + WHERE key BETWEEN ? AND ? AND typeof(value) = 'text' + `); + + const sessions: LocalSession[] = []; + + for (const composer of parsedHeaders.allComposers ?? []) { + if (!composer.composerId || !composer.createdAt) continue; + + // Only agent-mode sessions have per-turn bubble checkpoint data + if (composer.unifiedMode !== 'agent') continue; + if (composer.isArchived) continue; + + let totalTokens = 0; + let model: string | null = null; + + const hashes = hashesBySession.get(composer.composerId); + if (hashes && hashes.length > 0) { + // Newer agentKv format: decode protobuf blobs + const blobs = hashes + .map((h) => blobByHash.get(h)) + .filter((b): b is string => b !== undefined); + const agg = aggregateBubbles(blobs); + totalTokens = agg.totalTokens; + model = agg.model; + } else { + // Older bubbleId JSON format: sum tokenCount fields via BETWEEN range scan + const bubbleTokens = readBubbleIdTokens(bubbleIdStmt, composer.composerId); + if (bubbleTokens) { + totalTokens = bubbleTokens.input + bubbleTokens.output; + } else { + // Newest format: contextTokensUsed in composerData (final context-window size) + totalTokens = readComposerDataTokens(globalDb, composer.composerId) ?? 0; + } + } + + if (totalTokens === 0) continue; + + sessions.push( + buildCursorSession({ + composerId: composer.composerId, + title: composer.name ?? null, + createdAt: composer.createdAt, + lastUpdatedAt: composer.lastUpdatedAt ?? composer.createdAt, + workspacePath: resolveWorkspacePath(composer.workspaceIdentifier), + totalTokens, + rawModel: model, + }), + ); + } + + return sessions; } } diff --git a/packages/cli/src/schemas/session.ts b/packages/cli/src/schemas/session.ts index 7809e27..fa9582b 100644 --- a/packages/cli/src/schemas/session.ts +++ b/packages/cli/src/schemas/session.ts @@ -5,6 +5,8 @@ export const TokensSchema = z.object({ output: z.number().int().nonnegative(), cacheRead: z.number().int().nonnegative(), cacheWrite: z.number().int().nonnegative(), + /** When true, token counts are estimates rather than exact API-reported values */ + isApproximate: z.boolean().optional(), }); export const LocalSessionSchema = z.object({ diff --git a/packages/cli/src/services/api.ts b/packages/cli/src/services/api.ts index 41aa12b..4728022 100644 --- a/packages/cli/src/services/api.ts +++ b/packages/cli/src/services/api.ts @@ -108,7 +108,7 @@ export class ApiClient { outputTokens: t.output, cacheReadTokens: t.cacheRead, cacheWriteTokens: t.cacheWrite, - isApproximate: false, + isApproximate: t.isApproximate ?? false, } : null, }; diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts index 6142857..7f6e084 100644 --- a/packages/cli/tsup.config.ts +++ b/packages/cli/tsup.config.ts @@ -3,10 +3,13 @@ import { defineConfig } from 'tsup'; export default defineConfig({ entry: ['src/index.ts'], format: ['esm'], - target: 'node20', + target: 'node22', clean: true, dts: true, sourcemap: true, + // node:sqlite is experimental and not in esbuild's built-in list, so we mark + // it as external explicitly to prevent the node: prefix from being stripped. + external: ['node:sqlite'], banner: { js: '#!/usr/bin/env node', },