UNPKG

@jovan158/vantage

Version:

See and control what your coding agent does

213 lines (212 loc) • 7.61 kB
// Per-user state under ~/.vantage (not per project): the price list from // `vantage pricing update`, a pointer to the most recently started session so // `vantage watch` finds it from any directory, and an index of all sessions // for `vantage stats` and `vantage search` (session logs live per project). // // VANTAGE_HOME relocates it — for tests, and for machines where the home // directory is not writable. import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { sessionEventsPath } from "./events.js"; export function vantageHome() { return process.env.VANTAGE_HOME || path.join(os.homedir(), ".vantage"); } function lastSessionPath() { return path.join(vantageHome(), "last-session.json"); } function sessionIndexPath() { return path.join(vantageHome(), "sessions.jsonl"); } // Best effort: a home directory that cannot be written must not stop a run. export function recordLastSession(ref) { try { fs.mkdirSync(vantageHome(), { recursive: true }); fs.writeFileSync(lastSessionPath(), JSON.stringify(ref) + "\n"); fs.appendFileSync(sessionIndexPath(), JSON.stringify(ref) + "\n"); } catch { /* watch, stats and search then only see sessions in their own directory */ } } // Is the process still there? Signal 0 only checks; EPERM means it exists but // belongs to someone else. Works on Windows too. export function processAlive(pid) { try { process.kill(pid, 0); return true; } catch (err) { return err.code === "EPERM"; } } // Running = no session_end yet and its `vantage run` process is alive. Only // the first and last lines of the log are read, so this stays cheap for long // sessions. Logs from before the pid was recorded count as running while // they were written to in the last 30 minutes. export function sessionRunning(ref, nowMs = Date.now()) { const file = sessionEventsPath(ref.cwd, ref.sessionId); let fd = null; try { const size = fs.statSync(file).size; fd = fs.openSync(file, "r"); const headBuf = Buffer.alloc(Math.min(size, 4096)); fs.readSync(fd, headBuf, 0, headBuf.length, 0); const tailLen = Math.min(size, 4096); const tailBuf = Buffer.alloc(tailLen); fs.readSync(fd, tailBuf, 0, tailLen, size - tailLen); const last = tailBuf.toString("utf8").trim().split("\n").at(-1) ?? ""; if (last.includes('"type":"session_end"')) return false; const first = headBuf.toString("utf8").split("\n")[0] ?? ""; const pid = JSON.parse(first).pid; if (typeof pid === "number") return processAlive(pid); return nowMs - fs.statSync(file).mtimeMs < 30 * 60_000; } catch { return false; } finally { if (fd !== null) fs.closeSync(fd); } } // A session by id: in `cwd` first, then anywhere this machine recorded it. export function findSession(sessionId, cwd) { if (fs.existsSync(sessionEventsPath(cwd, sessionId))) return { cwd, sessionId }; return knownSessions().find((r) => r.sessionId === sessionId) ?? null; } // Every session this machine has recorded, plus those found in `cwd` (older // sessions predate the index). Sessions whose log is gone are skipped. export function knownSessions(cwd) { const seen = new Map(); const add = (ref) => { const key = `${path.resolve(ref.cwd)}|${ref.sessionId}`; if (!seen.has(key) && fs.existsSync(sessionEventsPath(ref.cwd, ref.sessionId))) seen.set(key, ref); }; try { for (const line of fs.readFileSync(sessionIndexPath(), "utf8").split("\n")) { if (!line.trim()) continue; try { const r = JSON.parse(line); if (typeof r.cwd === "string" && typeof r.sessionId === "string") add({ cwd: r.cwd, sessionId: r.sessionId }); } catch { /* skip a damaged line */ } } } catch { /* no index yet */ } if (cwd) { const base = path.join(cwd, ".vantage", "sessions"); try { for (const id of fs.readdirSync(base)) add({ cwd, sessionId: id }); } catch { /* no sessions here */ } } return [...seen.values()]; } // Drops index entries whose log is gone (pruned, or the project deleted) and // duplicates. A session recorded while this runs is kept: the file is read // again right before it is replaced, and anything appended since is carried // over. Returns how many entries were dropped. export function compactSessionIndex() { const file = sessionIndexPath(); let before; try { before = fs.readFileSync(file, "utf8"); } catch { return 0; } const kept = []; const seen = new Set(); let dropped = 0; for (const line of before.split("\n")) { if (!line.trim()) continue; try { const r = JSON.parse(line); if (typeof r.cwd !== "string" || typeof r.sessionId !== "string") throw new Error("not a session"); const key = `${path.resolve(r.cwd)}|${r.sessionId}`; if (seen.has(key) || !fs.existsSync(sessionEventsPath(r.cwd, r.sessionId))) throw new Error("gone"); seen.add(key); kept.push(JSON.stringify({ cwd: r.cwd, sessionId: r.sessionId })); } catch { dropped++; } } if (dropped === 0) return 0; const tmp = `${file}.${process.pid}.tmp`; try { const now = fs.readFileSync(file, "utf8"); const appended = now.startsWith(before) ? now.slice(before.length).split("\n").filter((l) => l.trim()) : []; fs.writeFileSync(tmp, [...kept, ...appended].map((l) => l + "\n").join("")); fs.renameSync(tmp, file); return dropped; } catch { fs.rmSync(tmp, { force: true }); return 0; } } function statSignature(p) { try { const s = fs.statSync(p); return `${s.mtimeMs}:${s.size}`; } catch { return "-"; } } // knownSessions() for a loop that asks every half second: the list is built // again only when the index or the project's session folder changed, and at // least every `maxAgeMs` (a session folder can appear before its log). export class SessionList { signature = ""; builtAt = 0; refs = []; cwd; maxAgeMs; constructor(cwd, maxAgeMs = 5000) { this.cwd = cwd; this.maxAgeMs = maxAgeMs; } get(nowMs = Date.now()) { const signature = [sessionIndexPath(), ...(this.cwd ? [path.join(this.cwd, ".vantage", "sessions")] : [])].map(statSignature).join("|"); if (signature !== this.signature || nowMs - this.builtAt >= this.maxAgeMs) { this.refs = knownSessions(this.cwd); this.signature = signature; this.builtAt = nowMs; } return this.refs; } } export function readLastSession() { try { const ref = JSON.parse(fs.readFileSync(lastSessionPath(), "utf8")); if (typeof ref.cwd !== "string" || typeof ref.sessionId !== "string") return null; // The project may have been moved or its sessions deleted since. if (!fs.existsSync(sessionEventsPath(ref.cwd, ref.sessionId))) return null; return { cwd: ref.cwd, sessionId: ref.sessionId }; } catch { return null; } }