@jovan158/vantage
Version:
See and control what your coding agent does
402 lines (401 loc) • 20.9 kB
JavaScript
// Live session view for a SECOND terminal (tmux pane, split window).
//
// The wrapped agent owns its own terminal — Vantage runs it with inherited
// stdio precisely so its TUI is never redrawn or corrupted (CONCEPT.md §6b:
// observe, don't re-render). So the live meter does not overlay the agent;
// it renders here, driven by the append-only event log. Zero risk to the
// agent's UI, zero dependencies.
import fs from "node:fs";
import path from "node:path";
import { EventLog, sessionDir } from "./events.js";
import { extractRateLimit } from "./ratelimit.js";
import { agentShort } from "./agents/index.js";
import { summarizeActions, formatActionSummary } from "./policy.js";
import { summarize, relativeTarget, shortenPaths } from "./replay.js";
import { formatCost } from "./pricing.js";
import { readLastSession } from "./home.js";
import { plain } from "./sanitize.js";
const C = {
dim: "\x1b[2m",
reset: "\x1b[0m",
bold: "\x1b[1m",
cyan: "\x1b[36m",
green: "\x1b[32m",
yellow: "\x1b[33m",
red: "\x1b[31m",
gray: "\x1b[90m",
};
function noColor() {
return new Proxy({}, { get: () => "" });
}
function fmtTokens(n) {
if (n < 1000)
return String(Math.round(n));
if (n < 1_000_000)
return (n / 1000).toFixed(n < 10_000 ? 1 : 0) + "k";
return (n / 1_000_000).toFixed(1) + "M";
}
// "45s", "12m", "3h20m", "1d 9h".
function span(ms) {
const s = Math.max(0, Math.round(ms / 1000));
if (s < 60)
return `${s}s`;
const m = Math.floor(s / 60);
if (m < 60)
return `${m}m`;
const h = Math.floor(m / 60);
if (h < 24)
return `${h}h${m % 60 ? String(m % 60).padStart(2, "0") + "m" : ""}`;
return `${Math.floor(h / 24)}d ${h % 24}h`;
}
const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
// Local clock time; with the weekday when it is not today.
function clock(ms, nowMs) {
const d = new Date(ms);
const hm = `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
return d.toDateString() === new Date(nowMs).toDateString() ? hm : `${DAYS[d.getDay()]} ${hm}`;
}
function clockSecs(ms) {
const d = new Date(ms);
return [d.getHours(), d.getMinutes(), d.getSeconds()].map((n) => String(n).padStart(2, "0")).join(":");
}
function fit(s, max) {
const one = s.replace(/\s+/g, " ").trim();
return one.length > max ? one.slice(0, Math.max(0, max - 1)) + "…" : one;
}
function bar(fraction, width) {
const filled = Math.round(Math.min(1, Math.max(0, fraction)) * width);
return "█".repeat(filled) + "░".repeat(width - filled);
}
// Green while there is room, yellow when it gets tight, red near the end.
function levelColor(c, fraction) {
return fraction >= 0.9 ? c.red : fraction >= 0.7 ? c.yellow : c.green;
}
// The agent a session ran, from its first event.
function sessionAgent(events) {
const start = events.find((e) => e.type === "session_start");
return agentShort(start?.type === "session_start" ? start.agent : undefined);
}
// One sentence on what the agent is doing right now, for a running session.
export function statusSentence(events, now, projectPath, c, width) {
const at = (e) => Date.parse(e.ts);
const who = sessionAgent(events);
const turns = events.filter((e) => e.type === "usage" && !e.background);
const lastTurn = turns.at(-1);
const lastRequest = events.filter((e) => e.type === "request").at(-1);
const lastDecision = events.filter((e) => e.type === "decision").at(-1);
// A question is open until Claude sends its next request, which it does
// once the tool has run — so an approved tool that is still running looks
// the same. Hence "requested", with its age, rather than "waiting".
// Claude Code runs our hook while the reply still streams, so the question
// can predate the turn's usage record.
const openSince = (e) => !!e && (!lastRequest || at(e) >= at(lastRequest));
if (lastDecision?.type === "decision" && lastDecision.decision === "ask" && openSince(lastDecision)) {
return `${c.yellow}${c.bold}Approval requested ${span(now - at(lastDecision))} ago:${c.reset} ${fit(`${lastDecision.tool} ${relativeTarget(lastDecision.target ?? "", projectPath)}`, width - 40)}`;
}
if (lastRequest && (!lastTurn || at(lastRequest) > at(lastTurn))) {
return `${c.cyan}${c.bold}${who} is thinking…${c.reset} ${c.dim}${span(now - at(lastRequest))}${c.reset}`;
}
// A turn that called tools is followed by the next one once they ran.
// Anthropic and Chat Completions say so in the stop reason; for the rest
// the calls themselves tell.
const calledTools = lastTurn?.stopReason === "tool_use" ||
lastTurn?.stopReason === "tool_calls" ||
(!!lastTurn?.stopReason && !["end_turn", "stop", "stop_sequence", "max_tokens"].includes(lastTurn.stopReason) && (lastTurn.tools?.length ?? 0) > 0);
if (lastTurn && calledTools) {
return `${c.cyan}${c.bold}${who} is working:${c.reset} ${fit(describeCalls(lastTurn, projectPath), width - 20)}`;
}
if (lastTurn)
return `${c.green}${c.bold}${who} replied.${c.reset} ${c.dim}${span(now - at(lastTurn))} ago${c.reset}`;
return `${c.dim}Waiting for the first message…${c.reset}`;
}
// One bar per quota window, with the reset as clock time, plus Anthropic's
// own warning state when it is not plain "allowed".
export function quotaLines(snap, now, c) {
const lines = [];
const label = (key) => (key === "5h" ? "5-hour" : key === "7d" ? "weekly" : key);
const ordered = [...(snap.unified?.windows ?? [])].sort((a, z) => (a.key === "5h" ? -1 : z.key === "5h" ? 1 : 0));
for (const w of ordered) {
if (w.utilization == null)
continue;
const pct = `${Math.round(w.utilization * 100)}%`.padStart(4);
const reset = w.resetUnix != null ? `resets ${clock(w.resetUnix * 1000, now)} (in ${span(w.resetUnix * 1000 - now)})` : "";
lines.push(` ${label(w.key).padEnd(7)} ${levelColor(c, w.utilization)}${bar(w.utilization, 20)}${c.reset} ${c.bold}${pct}${c.reset} ${c.dim}${reset}${c.reset}`);
}
const status = snap.unified?.status;
if (status === "rejected")
lines.push(` ${c.red}${c.bold}Limit reached — Anthropic is rejecting requests until the reset.${c.reset}`);
else if (status && status !== "allowed")
lines.push(` ${c.yellow}Anthropic reports you are close to your limit.${c.reset}`);
return lines;
}
function describeCalls(e, project) {
const calls = e.calls ?? (e.tools ?? []).map((tool) => ({ tool }));
return calls.map((k) => (k.target ? `${k.tool} ${relativeTarget(k.target, project)}` : k.tool)).join(", ");
}
// One frame of the live view. Pure: same inputs, same string. Every line
// answers something you want to know mid-session: what is Claude doing, will
// my limit hold, what did it touch, what does it cost. The conversation
// itself is in Claude Code's own window, and in `vantage replay`.
export function renderLive(events, opts) {
const c = opts.color === false ? noColor() : C;
const now = opts.nowMs ?? Date.now();
const width = Math.max(50, Math.min(opts.width ?? 80, 140));
const s = summarize(opts.sessionId, events);
const at = (e) => Date.parse(e.ts);
const start = events.find((e) => e.type === "session_start");
const end = events.find((e) => e.type === "session_end");
const turns = events.filter((e) => e.type === "usage" && !e.background);
const lastTurn = turns.at(-1);
const snapshots = [];
for (const e of events) {
if (e.type !== "ratelimit")
continue;
const snap = extractRateLimit(e.raw);
if (snap)
snapshots.push({ ts: at(e), snap });
}
const latest = snapshots.at(-1)?.snap;
const windows = latest?.unified?.windows ?? [];
const onSubscription = windows.length > 0;
const lines = [];
const section = (title, extra = "") => {
lines.push("");
lines.push(`${c.bold}${title}${c.reset}${extra ? `${c.dim} · ${extra}${c.reset}` : ""}`);
};
// --- Header: which session, how long, which model, where.
const startedMs = start ? at(start) : now;
const endedMs = end ? at(end) : null;
const projectPath = opts.project ?? (start?.type === "session_start" ? start.project : undefined);
// Folder name only (the log may come from Windows or POSIX); the full path
// when watching a session from another directory.
const projectName = projectPath ? plain(opts.project ?? projectPath.split(/[\\/]/).filter(Boolean).at(-1) ?? projectPath) : null;
const state = end && end.type === "session_end"
? `${c.gray}ended${end.exitCode != null ? ` (exit ${end.exitCode})` : ""}${c.reset}`
: `${c.green}running${c.reset}`;
lines.push(`${c.bold}vantage${c.reset} ${c.dim}·${c.reset} ${state} ` +
`${c.dim}· ${span((endedMs ?? now) - startedMs)}` +
`${lastTurn?.model ? ` · ${lastTurn.model}` : ""}${projectName ? ` · ${fit(projectName, 40)}` : ""}${c.reset}`);
lines.push(`${c.dim}${opts.sessionId}${c.reset}`);
// --- Status: one sentence on what is happening right now.
lines.push("");
lines.push(end
? `${c.dim}Session finished ${span(now - (endedMs ?? now))} ago — full timeline: vantage replay ${opts.sessionId}${c.reset}`
: statusSentence(events, now, projectPath, c, width));
// --- Secrets sent to the API: rare, and the one thing here you may have
// to act on (rotate the key), so right under the status.
const secrets = events.filter((e) => e.type === "secret");
for (const e of secrets.slice(-3)) {
if (e.type !== "secret")
continue;
lines.push(`${c.red}${c.bold}Sent to the API:${c.reset}${c.red} ${e.kind} (${e.masked}) from ${shortenPaths(e.source, projectPath)}${c.reset}`);
}
if (secrets.length > 3)
lines.push(`${c.red}… and ${secrets.length - 3} more — vantage replay ${opts.sessionId}${c.reset}`);
// --- Limits: will the quota hold?
if (onSubscription) {
section("Limits");
if (latest)
lines.push(...quotaLines(latest, now, c));
// Share of the 5-hour window used since this session started, and whether
// the current pace runs it out before it resets. The windows are account-
// wide, so other Claude use in the meantime counts too.
const fiveH = snapshots
.map((x) => ({ ts: x.ts, w: x.snap.unified?.windows.find((w) => w.key === "5h") }))
.filter((x) => x.w?.utilization != null);
const first = fiveH[0];
const last = fiveH.at(-1);
if (first && last) {
const delta = last.w.utilization - first.w.utilization;
const notes = [];
if (delta >= 0.005)
notes.push(`this session so far: +${Math.round(delta * 100)}% of the 5-hour limit`);
let warning = null;
const elapsed = last.ts - first.ts;
if (delta > 0 && elapsed >= 120_000 && last.w.resetUnix != null && !end) {
const fullAt = last.ts + ((1 - last.w.utilization) / delta) * elapsed;
if (fullAt < last.w.resetUnix * 1000) {
warning = `At this pace the 5-hour limit runs out around ${clock(fullAt, now)}, before it resets.`;
}
else {
notes.push("at this pace it lasts until the reset");
}
}
if (notes.length)
lines.push(` ${c.dim}${notes.join(" · ")}${c.reset}`);
if (warning)
lines.push(` ${c.red}${c.bold}${warning}${c.reset}`);
}
}
// --- This session: turns, cost, budget, context.
section("This session");
const callCount = turns.reduce((n, e) => n + (e.calls?.length ?? e.tools?.length ?? 0), 0);
lines.push(` ${c.dim}work${c.reset} ${c.bold}${s.messages}${c.reset} message(s) from you` +
`${c.dim} → ${c.reset}${s.turns} model call(s)${callCount ? `, ${callCount} tool call(s)` : ""}`);
lines.push(` ${c.dim}cost${c.reset} ${c.bold}${formatCost(s.costUsd, s.unpriced, s.requests)}${c.reset}` +
(onSubscription ? `${c.dim} API-equivalent; on your subscription the limits above count${c.reset}` : ""));
const budget = start?.type === "session_start" ? start.budget : undefined;
const reached = events.filter((e) => e.type === "budget").at(-1);
if (budget) {
const parts = [];
if (budget.maxCostUsd != null) {
const used = s.costUsd / budget.maxCostUsd;
parts.push(`${levelColor(c, used)}${bar(used, 10)}${c.reset} ${Math.round(used * 100)}% of $${budget.maxCostUsd}`);
}
if (budget.maxQuota != null)
parts.push(`approval needed from ${Math.round(budget.maxQuota * 100)}% quota`);
lines.push(` ${c.dim}budget${c.reset} ${parts.join(`${c.dim} · ${c.reset}`)}`);
if (reached?.type === "budget" && reached.state === "reached") {
lines.push(` ${c.red}${c.bold}Budget reached — every action now needs your approval.${c.reset}`);
}
}
if (lastTurn) {
const context = lastTurn.in + lastTurn.cache_read + lastTurn.cache_write;
const allInput = turns.reduce((n, e) => n + e.in + e.cache_read + e.cache_write, 0);
const cached = turns.reduce((n, e) => n + e.cache_read, 0);
lines.push(` ${c.dim}context${c.reset} ${c.bold}${fmtTokens(context)}${c.reset} tokens sent with the last message` +
(allInput ? `${c.dim} · ${Math.round((cached / allInput) * 100)}% of all input came from cache${c.reset}` : ""));
}
const entries = [];
const allTools = [];
for (const e of events) {
if (e.type === "usage" && !e.background) {
allTools.push(...(e.tools ?? []));
const calls = e.calls ?? (e.tools ?? []).map((tool) => ({ tool }));
for (const k of calls) {
// The hook may have decided on this call already (see status above).
const decided = entries.find((x) => x.awaitingCall && x.tool === k.tool && x.target === k.target);
if (decided)
decided.awaitingCall = false;
else
entries.push({ ts: at(e), tool: k.tool, ...(k.target ? { target: k.target } : {}) });
}
}
else if (e.type === "decision") {
// Mark the logged call if it is already there, else note the decision
// and let the call join it when its turn is logged.
const match = [...entries].reverse().find((x) => x.tool === e.tool && x.target === e.target && !x.mark);
if (match)
match.mark = e.decision;
else
entries.push({ ts: at(e), tool: e.tool, ...(e.target ? { target: e.target } : {}), mark: e.decision, awaitingCall: true });
}
}
const edited = new Set(entries.filter((x) => x.target && /^(Write|Edit|MultiEdit|NotebookEdit)$/.test(x.tool)).map((x) => x.target));
const summary = [formatActionSummary(summarizeActions(allTools)), edited.size ? `${edited.size} file(s) edited` : null]
.filter(Boolean)
.join(" · ");
section("Activity", summary);
if (entries.length === 0) {
lines.push(` ${c.dim}no tool calls yet${c.reset}`);
}
else {
const shown = entries.slice(-6);
if (entries.length > shown.length)
lines.push(` ${c.dim}… ${entries.length - shown.length} earlier${c.reset}`);
for (const x of shown) {
const mark = x.mark === "deny" ? `${c.red}blocked ${c.reset}` : x.mark === "ask" ? `${c.yellow}asked ${c.reset}` : " ";
lines.push(` ${c.dim}${clockSecs(x.ts)}${c.reset} ${mark}${x.tool.padEnd(9)} ${fit(relativeTarget(x.target ?? "", projectPath), width - 32)}`);
}
}
lines.push("");
lines.push(`${c.dim}${end ? "session finished" : `Ctrl-C stops watching — ${sessionAgent(events)} keeps running`}${c.reset}`);
return lines.join("\n");
}
// Several sessions at once: the quota once (it is shared by the account),
// then one block per session with what it is doing and what it cost.
export function renderOverview(items, opts) {
const c = opts.color === false ? noColor() : C;
const now = opts.nowMs ?? Date.now();
const width = Math.max(50, Math.min(opts.width ?? 80, 140));
const lines = [];
lines.push(`${c.bold}vantage${c.reset} ${c.dim}·${c.reset} ${c.green}${items.length} sessions running${c.reset}`);
// The newest quota reading across all sessions.
let latest = null;
for (const { events } of items) {
for (const e of events) {
if (e.type !== "ratelimit")
continue;
const snap = extractRateLimit(e.raw);
const ts = Date.parse(e.ts);
if (snap?.unified?.windows.length && (!latest || ts > latest.ts))
latest = { ts, snap };
}
}
if (latest) {
lines.push("");
lines.push(`${c.bold}Limits${c.reset}${c.dim} · shared by all sessions${c.reset}`);
lines.push(...quotaLines(latest.snap, now, c));
}
const name = (item) => {
const start = item.events.find((e) => e.type === "session_start");
const project = (start?.type === "session_start" && start.project) || item.ref.cwd;
return plain(project.split(/[\\/]/).filter(Boolean).at(-1) ?? project);
};
const nameWidth = Math.min(20, Math.max(...items.map((i) => name(i).length)));
const sorted = [...items].sort((a, z) => a.ref.sessionId.localeCompare(z.ref.sessionId));
lines.push("");
lines.push(`${c.bold}Sessions${c.reset}`);
for (const item of sorted) {
const start = item.events.find((e) => e.type === "session_start");
const project = start?.type === "session_start" ? start.project : undefined;
const s = summarize(item.ref.sessionId, item.events);
const started = start ? Date.parse(start.ts) : now;
const status = statusSentence(item.events, now, project, c, width - nameWidth - 10);
lines.push(` ${c.bold}${fit(name(item), nameWidth).padEnd(nameWidth)}${c.reset} ${c.dim}${span(now - started).padStart(6)}${c.reset} ${status}`);
const secrets = item.events.filter((e) => e.type === "secret").length;
const reached = item.events.filter((e) => e.type === "budget").at(-1);
const facts = [
formatCost(s.costUsd, s.unpriced, s.requests),
`${s.messages} message(s)`,
...(reached?.type === "budget" && reached.state === "reached" ? [`${c.red}budget reached${c.reset}${c.dim}`] : []),
...(secrets ? [`${c.red}${secrets} secret(s) sent${c.reset}${c.dim}`] : []),
];
lines.push(` ${" ".repeat(nameWidth)} ${" ".repeat(6)} ${c.dim}${facts.join(" · ")}${c.reset}`);
}
lines.push("");
lines.push(`${c.dim}One session in detail:${c.reset}`);
for (const item of sorted)
lines.push(` ${c.dim}vantage watch ${item.ref.sessionId} ${name(item)}${c.reset}`);
lines.push("");
lines.push(`${c.dim}Ctrl-C stops watching — the agents keep running${c.reset}`);
return lines.join("\n");
}
// The session `vantage watch` should show: the newest one started from this
// directory, or — when a session was started elsewhere more recently — that
// one. Session ids start with their start time, so they compare as strings.
// A pinned id is looked up here first, then as the last started session.
export function findWatchTarget(cwd, pinned) {
const last = readLastSession();
if (pinned) {
if (fs.existsSync(path.join(sessionDir(cwd, pinned), "events.jsonl")))
return { cwd, sessionId: pinned };
return last && last.sessionId === pinned ? last : null;
}
const local = newestSessionId(cwd);
if (local && (!last || local >= last.sessionId))
return { cwd, sessionId: local };
return last;
}
// Newest session id on disk (ids are timestamp-prefixed, so lexicographic
// order is chronological), or null when none exist yet.
export function newestSessionId(cwd) {
const base = path.join(cwd, ".vantage", "sessions");
if (!fs.existsSync(base))
return null;
const ids = fs.readdirSync(base).filter((id) => fs.existsSync(path.join(sessionDir(cwd, id), "events.jsonl")));
if (ids.length === 0)
return null;
return ids.sort().at(-1) ?? null;
}
export function readSessionEvents(cwd, sessionId) {
const file = path.join(sessionDir(cwd, sessionId), "events.jsonl");
if (!fs.existsSync(file))
return [];
try {
return new EventLog(file).readAll();
}
catch {
return []; // a half-written final line; the next poll picks it up
}
}