/** * @file Cursor sessions, read from the agent transcripts Cursor writes under * `~/.cursor/projects//agent-transcripts//.jsonl` (the * Cursor CLI, `cursor-agent`, and the Cursor editor's agent both write there). * This is how a Cursor session reaches `sessions`, as `tool = 'cursor'`. * * NO tokens and NO model: a transcript line is only `{role, message:{content}}` * (plus `{type:"turn_ended"}`) — no usage, no model id, no timestamps — so a * Cursor session gets no `token_usage` rows and no cost. Nothing is guessed. * * - the session id is the transcript's file name; * - the name is the first `` line (60 chars); * - the start is the file's birth time (its mtime where the filesystem keeps * none), the last activity its mtime: active within 2 minutes, else * completed, ended at that write; * - the cwd is the project slug walked back onto the filesystem — the slug * turns every `/` and every other non-alphanumeric character into `-`, so it * is resolved only when exactly such a path still exists, else left empty. * A file is re-read when its mtime changes, and the mtime per file lives in * `cursor_transcripts`, so a session the user deleted stays deleted until * Cursor writes to it again. No transcript is ever stored as `transcript_path`. * @author Nguyễn Ngọc Trí Vĩ */ const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); const { db, stmts } = require("../db"); const ACTIVE_MS = 120_000; // the window codex-usage.js calls a session active const FUTURE_SLACK_MS = 1000; const ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; const HEAD_BYTES = 256 * 1024; /** `~/.cursor`, or `AGENTHUB_CURSOR_DIR`; read at call time. */ function getCursorDir() { return process.env.AGENTHUB_CURSOR_DIR || path.join(os.homedir(), ".cursor"); } const norm = (s) => s.replace(/[^A-Za-z0-9]/g, "-"); // A walk enters at most this many directories: a member can build a tree (or // a symlink loop) whose matching paths grow exponentially with the slug. const MAX_VISITS = 500; /** * The existing directory whose path slugs to `slug`, or null. Walks from `/`, * trying every real directory (never a symlink) whose own slug is a prefix of * what is left; gives up (null) after MAX_VISITS directories. */ function cwdFromSlug(slug, root = "/") { let visits = 0; const walk = (dir, rest, depth) => { if (!rest) return dir; if (depth > 40 || ++visits > MAX_VISITS) return null; let entries; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return null; } for (const e of entries) { if (!e.isDirectory()) continue; const n = norm(e.name); if (rest !== n && !rest.startsWith(`${n}-`)) continue; const hit = walk(path.join(dir, e.name), rest.slice(n.length + 1), depth + 1); if (hit || visits > MAX_VISITS) return hit; } return null; }; const want = norm(slug); return want ? walk(root, want, 0) : null; } /** The first prompt's first line (60 chars) from the transcript's head, or null. */ function firstPrompt(file) { let text; try { const fd = require("./run-as").openSafe(file); try { const buf = Buffer.alloc(Math.min(fs.fstatSync(fd).size, HEAD_BYTES)); fs.readSync(fd, buf, 0, buf.length, 0); text = buf.toString("utf8"); } finally { fs.closeSync(fd); } } catch { return null; } for (const line of text.split("\n")) { let o; try { o = JSON.parse(line); } catch { continue; } if (o?.role !== "user" || !Array.isArray(o.message?.content)) continue; for (const part of o.message.content) { if (part?.type !== "text" || typeof part.text !== "string") continue; const q = /\s*([\s\S]*?)\s*<\/user_query>/.exec(part.text); const prompt = (q ? q[1] : part.text.replace(/[\s\S]*?<\/timestamp>/, "")).trim(); if (prompt) return prompt.split("\n")[0].slice(0, 60); } } return null; } /** Every agent transcript, with its project slug, birth and mtime — this * account's, then every member's (S3: their own home). */ function transcriptFiles() { const members = require("./run-as") .memberHomes() .map((h) => path.join(h, ".cursor")); return [getCursorDir(), ...members].flatMap((dir) => transcriptFilesIn(dir)); } function transcriptFilesIn(cursorDir) { const projects = path.join(cursorDir, "projects"); let slugs; try { slugs = fs.readdirSync(projects); } catch { return []; } const out = []; for (const slug of slugs) { const dir = path.join(projects, slug, "agent-transcripts"); let ids; try { ids = fs.readdirSync(dir); } catch { continue; } for (const id of ids) { if (!ID.test(id)) continue; const file = path.join(dir, id, `${id}.jsonl`); try { const st = fs.statSync(file); if (!st.isFile()) continue; const mtime = Math.floor(st.mtimeMs); out.push({ path: file, id, slug, mtime, birth: Math.floor(st.birthtimeMs) || mtime }); } catch { /* no transcript (yet) */ } } } return out; } const getSeen = db.prepare("SELECT * FROM cursor_transcripts WHERE path = ?"); const upsertSeen = db.prepare( `INSERT INTO cursor_transcripts (path, session_id, mtime_ms, updated_at) VALUES (?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) ON CONFLICT(path) DO UPDATE SET session_id = excluded.session_id, mtime_ms = excluded.mtime_ms, updated_at = excluded.updated_at` ); const insertSession = db.prepare( `INSERT INTO sessions (id, name, status, cwd, started_at, ended_at, updated_at, metadata, tool) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'cursor')` ); const updateSession = db.prepare( `UPDATE sessions SET name = ?, status = ?, ended_at = ?, updated_at = ?, cwd = COALESCE(cwd, ?) WHERE id = ? AND tool = 'cursor'` ); const settle = db.prepare( "UPDATE sessions SET status = 'completed', ended_at = ? WHERE id = ? AND tool = 'cursor'" ); // A write "in the future" (a clock that runs ahead) is not proof of activity. const fresh = (mtime, now) => mtime - now < FUTURE_SLACK_MS && now - mtime < ACTIVE_MS; /** Write one transcript's session row. @returns the broadcast type, or null. */ function writeSession(f, now) { const existing = stmts.getSession.get(f.id); if (existing && existing.tool !== "cursor") return null; // S3: a member's transcript names only their own folder and session. const cwd = existing?.cwd ? null : cwdFromSlug(f.slug); if (!require("./run-as").sameOwner(f.path, existing?.cwd || cwd)) return null; const lastWrite = new Date(f.mtime).toISOString(); const active = fresh(f.mtime, now); const status = active ? "active" : "completed"; const endedAt = active ? null : lastWrite; const name = firstPrompt(f.path) || existing?.name || `Cursor ${f.id.slice(0, 8)}`; if (existing) { updateSession.run( name, status, endedAt, lastWrite, cwd, f.id ); return "session_updated"; } insertSession.run( f.id, name, status, cwd, new Date(Math.min(f.birth, f.mtime)).toISOString(), endedAt, lastWrite, JSON.stringify({ source: "cursor-transcript" }) ); return "session_created"; } let running = null; /** * One pass over every transcript: a changed one is re-read, an unchanged one * only has an `active` session settled once it went quiet. * @returns {Promise<{files: number, sessions: number}>} */ function ingestCursorUsage({ broadcast = () => {}, now = Date.now() } = {}) { if (running) return running; running = (async () => { let read = 0; let written = 0; for (const f of transcriptFiles()) { try { const seen = getSeen.get(f.path); if (seen && seen.mtime_ms === f.mtime) { if (fresh(f.mtime, now)) continue; const s = stmts.getSession.get(f.id); if (s && s.tool === "cursor" && s.status === "active") { settle.run(new Date(f.mtime).toISOString(), s.id); broadcast("session_updated", stmts.getSession.get(s.id)); written += 1; } continue; } read += 1; const type = writeSession(f, now); upsertSeen.run(f.path, f.id, f.mtime); if (type) { written += 1; broadcast(type, stmts.getSession.get(f.id)); } } catch (err) { console.warn(`[cursor-usage] skipped ${f.path}: ${err.message}`); } await new Promise((r) => setImmediate(r)); } return { files: read, sessions: written }; })().finally(() => { running = null; }); return running; } /** First pass at once (in the background), then every DASHBOARD_CURSOR_POLL_MS (default 15 s; 0 disables). */ function startCursorUsagePoll(broadcast) { const POLL_MS = process.env.DASHBOARD_CURSOR_POLL_MS ? Number(process.env.DASHBOARD_CURSOR_POLL_MS) : 15_000; if (!Number.isFinite(POLL_MS) || POLL_MS <= 0) return; const tick = () => { ingestCursorUsage({ broadcast }).catch(() => {}); }; tick(); const timer = setInterval(tick, POLL_MS); if (timer.unref) timer.unref(); } module.exports = { getCursorDir, cwdFromSlug, firstPrompt, ingestCursorUsage, startCursorUsagePoll, };