UNPKG

@jovan158/vantage

Version:

See and control what your coding agent does

458 lines (457 loc) • 21.2 kB
// `vantage run`: start Claude Code behind the metering proxy, enforce rules // and budget through its PreToolUse hook, and sum up the session at the end. import { spawn } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { startProxy } from "../proxy.js"; import { Meter } from "../meter.js"; import { EventLog, ensureVantageGitignore, newSessionId, sessionDir, sessionEventsPath } from "../events.js"; import { resolveAdapter, knownAgents } from "../agents/index.js"; import { QuotaWatcher } from "../ratelimit.js"; import { shortenPaths } from "../replay.js"; import { recordLastSession } from "../home.js"; import { worthHarvesting } from "../harvest.js"; import { compileMemory } from "../memory.js"; import { loadPolicy, PolicyWatcher, needsEnforcement } from "../policy.js"; import { loadRules, policyFilePath } from "../rules.js"; import { hookInvocation } from "../hook.js"; import { writeHookConfig } from "../agents/hooks.js"; import { outboxPath, postAlert, takeAlerts } from "../outbox.js"; import { resolveCommand } from "../resolve.js"; import { BudgetGuard, InflightCounter, inflightPath, budgetStatePath, formatBudget, hasBudget, parseCost, parseQuota, } from "../budget.js"; import { formatCost, pricingHints } from "../pricing.js"; import { isGitRepo, isDirty, addSessionWorktree, commitSessionWork, sessionDiff, removeSessionWorktree, snapshotWorkingTree, treeDiff, } from "../git.js"; import { writeMeta } from "../session-meta.js"; import { terminal, log, warn, fmtFileLine } from "./output.js"; import { plain } from "../sanitize.js"; // Warn threshold as a fraction 0..1. VANTAGE_QUOTA_WARN accepts a fraction // (0.8) or a percent (80); default 90%. function warnThreshold() { const raw = process.env.VANTAGE_QUOTA_WARN; if (!raw) return 0.9; const n = Number(raw); if (!Number.isFinite(n) || n <= 0) return 0.9; return n > 1 ? Math.min(n / 100, 1) : n; } export async function cmdRun(argv, entry) { // Leading vantage-level flags come before the agent name. let isolate = false; let useMemory = true; let costRaw = process.env.VANTAGE_MAX_COST; let quotaRaw = process.env.VANTAGE_MAX_QUOTA; const rest = [...argv]; while (rest[0]?.startsWith("--")) { const arg = rest.shift(); const eq = arg.indexOf("="); const flag = eq === -1 ? arg : arg.slice(0, eq); const value = () => (eq === -1 ? rest.shift() : arg.slice(eq + 1)); if (flag === "--isolate") isolate = true; else if (flag === "--no-isolate") isolate = false; else if (flag === "--no-memory") useMemory = false; else if (flag === "--max-cost") costRaw = value(); else if (flag === "--max-quota") quotaRaw = value(); else { log(`unknown flag "${flag}"`); return 1; } } const budget = { maxCostUsd: null, maxQuota: null }; if (costRaw !== undefined) { budget.maxCostUsd = parseCost(costRaw); if (budget.maxCostUsd === null) { log(`--max-cost needs an amount in USD, e.g. --max-cost 2 (got "${costRaw}")`); return 1; } } if (quotaRaw !== undefined) { budget.maxQuota = parseQuota(quotaRaw); if (budget.maxQuota === null) { log(`--max-quota needs a percentage, e.g. --max-quota 80 (got "${quotaRaw}")`); return 1; } } const agentName = rest[0]; if (!agentName) { log("missing agent name. try: vantage run claude"); return 1; } const adapter = resolveAdapter(agentName); if (!adapter) { log(`unknown agent "${agentName}". known: ${knownAgents().join(", ")}`); return 1; } // Everything after "--" (or after the agent name) is passed to the agent. const afterAgent = rest.slice(1); const sep = afterAgent.indexOf("--"); const agentArgs = sep === -1 ? afterAgent : afterAgent.slice(sep + 1); const routes = adapter.routes(process.env); const cwd = process.cwd(); const sessionId = newSessionId(); if (ensureVantageGitignore(cwd)) log("created .vantage/.gitignore — session logs stay out of git"); const eventLog = new EventLog(sessionEventsPath(cwd, sessionId)); const meter = new Meter(); const quota = new QuotaWatcher(warnThreshold()); const effectivePolicy = loadPolicy(cwd); const rules = loadRules(policyFilePath(cwd)); const policy = new PolicyWatcher(effectivePolicy); const guard = hasBudget(budget) ? new BudgetGuard(budget, budgetStatePath(sessionDir(cwd, sessionId))) : null; // The agent's chat UI owns the terminal from launch until exit. Vantage // then shows alerts in that chat, through a hook the agent runs after each // reply (see src/outbox.ts); live numbers are in `vantage watch`. const interactive = adapter.isInteractive(agentArgs) && Boolean(process.stderr.isTTY); const chatAlerts = interactive && adapter.capabilities.chatAlerts; const outbox = chatAlerts ? outboxPath(sessionDir(cwd, sessionId)) : null; // Exchanges in flight, so a hook can wait until the reply before it is // metered: the budget check, and alerts that reply caused. const inflight = guard || chatAlerts ? new InflightCounter(inflightPath(sessionDir(cwd, sessionId))) : null; const seenSecrets = new Set(); // An alert goes to the chat while the agent's UI is open, else to stderr. const alert = (w) => { if (outbox && terminal.isHolding) postAlert(outbox, w); else warn(w); }; const onBudget = (change, notes) => { for (const note of notes) alert({ key: "budget", level: "warn", message: `budget: ${note}` }); if (!change) return; eventLog.append({ ts: new Date().toISOString(), type: "budget", state: change.kind, reason: change.reason }); if (change.kind === "reached") { alert({ key: "budget", level: "critical", message: `budget reached — ${change.reason}. Every action now needs your approval.` }); } else { log(`budget: ${change.reason} — actions run as before`); } }; // Optional git isolation: run the agent in a dedicated worktree/branch so the // user's working tree is never touched (CONCEPT.md problem ④). let worktree = null; let childCwd = cwd; if (isolate) { if (!isGitRepo(cwd)) { log("--isolate requires a git repository (none found here)"); return 1; } if (isDirty(cwd)) { log("note: working tree has uncommitted changes — isolation branches from HEAD, so those are not included"); } worktree = addSessionWorktree(cwd, sessionId); childCwd = worktree.path; log(`isolated on branch ${worktree.branch} (base ${worktree.baseSha.slice(0, 8)}) · worktree ${path.relative(cwd, worktree.path)}`); } // Without isolation, snapshot the working tree so the end of the session // can say exactly which files changed meanwhile. let startTree; if (!isolate && isGitRepo(cwd)) { const snap = snapshotWorkingTree(cwd); if (snap.ok) startTree = snap.tree; else log(`no change summary for this session: ${snap.reason}`); } const meta = { sessionId, agent: adapter.id, cwd, isolated: isolate, branch: worktree?.branch, baseSha: worktree?.baseSha, worktreePath: worktree?.path, createdAt: new Date().toISOString(), ...(startTree ? { startTree } : {}), }; writeMeta(cwd, meta); eventLog.append({ ts: new Date().toISOString(), type: "session_start", agent: adapter.id, project: cwd, pid: process.pid, ...(guard ? { budget } : {}), }); recordLastSession({ cwd, sessionId }); const proxy = await startProxy({ routes, agentName: adapter.name, // Lets watch say "Claude is thinking" while a chat turn is in flight. onRequest: (info) => { for (const f of info.secrets) { if (seenSecrets.has(f.fingerprint)) continue; seenSecrets.add(f.fingerprint); eventLog.append({ ts: new Date().toISOString(), type: "secret", kind: f.kind, masked: f.masked, source: f.source }); const source = shortenPaths(f.source, cwd); const article = /^[aeiou]/i.test(f.kind) ? "an" : "a"; const message = `${article} ${f.kind} (${f.masked}) was sent to the API, from ${source} — rotate it if it should not leave your machine`; alert({ key: `secret:${f.fingerprint}`, level: "critical", message }); } if (info.background) return; eventLog.append({ ts: new Date().toISOString(), type: "request" }); }, log: (msg) => terminal.info(`\x1b[2m[vantage:proxy]\x1b[0m ${plain(msg)}\n`), onExchange: (phase) => (phase === "start" ? inflight?.start() : inflight?.end()), onUsage: (e) => { eventLog.append(e); meter.add(e); log(meter.statusLine()); if (guard) { const t = meter.snapshot(); onBudget(guard.onCost(t.costUsd), guard.blindSpots(t.unpricedModels, t.requests)); } const rl = meter.rateLimitLine(); if (rl) log(rl); if (e.tools) { for (const notice of policy.observe(e.tools)) { alert({ key: notice.type, level: "warn", message: `policy: ${notice.message}` }); } } }, onRateLimit: (snapshot) => { meter.setRateLimit(snapshot); eventLog.append({ ts: new Date().toISOString(), type: "ratelimit", path: "/", raw: snapshot.raw, }); for (const w of quota.update(snapshot)) alert(w); if (guard) onBudget(guard.onQuota(snapshot), []); }, }); // Enforce action-type policy through the agent's hook (②). Only the agent // can stop a tool it is about to run — the proxy never sees the execution. // A hook after each reply carries alerts into the chat. const dir = sessionDir(cwd, sessionId); const enforcingRules = rules.filter((r) => r.level === "ask" || r.level === "deny"); const enforce = needsEnforcement(effectivePolicy) || enforcingRules.length > 0 || guard !== null; // What the hook needs, as environment variables for hooks that inherit the // agent's environment and as a file named in the hook command for those // that do not (see src/agents/hooks.ts). const hookEnv = {}; if (outbox) { hookEnv.VANTAGE_OUTBOX_FILE = outbox; hookEnv.VANTAGE_INFLIGHT_FILE = inflightPath(dir); } if (enforce) { // Pass the resolved policy explicitly: the hook runs with the agent's cwd // (a worktree under --isolate), which may not hold .vantage/policy.json. hookEnv.VANTAGE_POLICY = policyToEnv(effectivePolicy); // Rules are read from the project's file, wherever the agent runs. hookEnv.VANTAGE_POLICY_FILE = policyFilePath(cwd); const enforced = Object.entries(effectivePolicy) .filter(([, l]) => l === "ask" || l === "deny") .map(([t, l]) => `${t}:${l}`) .join(" "); if (enforced) log(`enforcing policy via ${adapter.name}'s hook — ${enforced}`); if (enforcingRules.length) log(`enforcing ${enforcingRules.length} file/command rule(s) from .vantage/policy.json`); // The hook records what it blocks or asks about, so watch and replay // can show it next to the calls that went through. hookEnv.VANTAGE_EVENTS_FILE = eventLog.filePath; if (guard) { hookEnv.VANTAGE_BUDGET_FILE = budgetStatePath(dir); hookEnv.VANTAGE_INFLIGHT_FILE = inflightPath(dir); log(`budget: every action needs approval once ${formatBudget(budget)} is reached`); } } // Project memory, through the agent's own mechanism (⑤). const memory = useMemory ? compileMemory(cwd) || null : null; const memoryFile = path.join(dir, "memory.md"); if (memory) { fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(memoryFile, memory); hookEnv.VANTAGE_MEMORY_FILE = memoryFile; } const hookConfigFile = path.join(dir, "hook-config.json"); writeHookConfig(hookConfigFile, hookEnv); const plan = adapter.prepare({ proxyUrl: proxy.url, sessionDir: dir, hook: hookInvocation(process.execPath, entry, [adapter.key, hookConfigFile]), enforce, chatAlerts, memory, memoryFile, cwd: childCwd, env: process.env, policy: effectivePolicy, rules, args: agentArgs, }); const finalArgs = [...plan.args, ...agentArgs]; for (const note of plan.notes) log(note); if (memory && plan.memory) log(`injected project memory (${memory.length} chars) via ${adapter.name}`); const all = [...new Set(routes.map((r) => (typeof r.upstream === "string" ? r.upstream : `${r.prefix} (per request)`)))]; const upstreams = all.length > 3 ? `${all.slice(0, 2).join(", ")} and ${all.length - 2} more` : all.join(", "); log(`session ${sessionId} · agent ${adapter.name} · upstream ${upstreams} (${proxy.via})`); log(`proxy ${proxy.url} → ${adapter.command}`); // A launch that never gets going must still end its session — otherwise // `watch` shows it as running forever — and must not leave an empty // isolation worktree behind. // Ends the quiet period and prints the alerts not shown in the chat yet. const releaseTerminal = () => { const held = terminal.release(); const waiting = takeAlerts(outbox ?? undefined); if (held.length === 0 && waiting.length === 0) return; log("during the session:"); for (const line of held) terminal.alert(line); for (const a of waiting) warn({ key: a.message, ...a }); }; const failLaunch = async (reason) => { releaseTerminal(); log(`could not launch ${adapter.command}: ${reason}`); eventLog.append({ ts: new Date().toISOString(), type: "session_end", exitCode: 127 }); if (worktree) removeSessionWorktree(cwd, worktree.path, worktree.branch); await proxy.close(); return 127; }; // VANTAGE_AGENT_PATH names the agent's executable outright, for installs the // automatic lookup gets wrong. It still goes through the resolver, so a path // to an npm `.cmd` shim works as well as one to the `.exe` itself. const override = process.env.VANTAGE_AGENT_PATH; if (override) { if (!fs.existsSync(override)) { return await failLaunch(`VANTAGE_AGENT_PATH points at ${override}, which does not exist`); } log(`agent from VANTAGE_AGENT_PATH: ${override}`); } // Windows cannot spawn npm's `claude.cmd` shims without a shell; resolve to // what the shim wraps instead (see src/resolve.ts). const target = resolveCommand(override || adapter.command); if (!target.ok) { const hint = override ? "" : " — or set VANTAGE_AGENT_PATH to the agent's executable"; return await failLaunch(target.reason + hint); } // From here until the agent exits, its chat UI owns the terminal. Anything // written now would land inside that UI, so live numbers go to // `vantage watch` and alerts wait until the end. if (interactive) { log(chatAlerts ? `live view: run \`vantage watch\` in a second terminal — alerts appear in ${adapter.name}'s chat` : `live view: run \`vantage watch\` in a second terminal — Vantage stays quiet here until ${adapter.name} exits`); terminal.hold(); } const child = spawn(target.resolved.command, [...target.resolved.prefix, ...finalArgs], { cwd: childCwd, stdio: "inherit", env: { ...process.env, ...plan.env, ...hookEnv }, }); const forward = (sig) => () => child.kill(sig); process.on("SIGINT", forward("SIGINT")); process.on("SIGTERM", forward("SIGTERM")); return await new Promise((resolve) => { child.on("error", (err) => { const reason = err.code === "ENOENT" ? "not found — is it installed and on PATH? (or set VANTAGE_AGENT_PATH to its executable)" : err.message; void failLaunch(reason).then(resolve); }); child.on("exit", async (code) => { releaseTerminal(); // What changed: the isolation branch, or the working tree since start. let changes = null; if (worktree) { changes = reportIsolatedChanges(cwd, sessionId, worktree, adapter.id); } else if (startTree) { const end = snapshotWorkingTree(cwd); if (end.ok) { changes = treeDiff(cwd, startTree, end.tree); writeMeta(cwd, { ...meta, endTree: end.tree }); if (changes.files.length) fs.writeFileSync(path.join(sessionDir(cwd, sessionId), "changes.patch"), changes.patch); } } eventLog.append({ ts: new Date().toISOString(), type: "session_end", exitCode: code, ...(changes ? { changes: { files: changes.files.slice(0, 500).map((f) => f.path), added: changes.added, removed: changes.removed } } : {}), }); const t = meter.snapshot(); log(`session end · ${t.requests} request(s) · in ${t.input} · out ${t.output} · ` + `cache ${t.cacheRead}r/${t.cacheWrite}w · ${formatCost(t.costUsd, t.unpriced, t.requests)}`); const rl = meter.rateLimitLine(); if (rl) log(rl); for (const hint of pricingHints(t.unpricedModels)) log(hint); log(`event log: ${eventLog.filePath}`); if (worktree && changes) { printIsolatedChanges(sessionId, worktree, changes); } else if (startTree && changes) { if (changes.files.length === 0) { log("no files changed during this session"); } else { log(`changed during this session: ${changes.files.length} file(s), +${changes.added}/-${changes.removed} (edits you made meanwhile included)`); for (const f of changes.files.slice(0, 10)) log(fmtFileLine(f)); if (changes.files.length > 10) log(` … and ${changes.files.length - 10} more`); log(`full diff: vantage review ${sessionId}`); } } // One quiet line, only when the session actually did something — memory // is never written automatically (see src/harvest.ts). if (worthHarvesting(eventLog.readAll())) { log(`worth remembering? vantage harvest ${sessionId}`); } await proxy.close(); resolve(code ?? 0); }); }); } // Commits the isolated session's work and returns its diff (empty when the // session changed nothing; the empty worktree is removed then). function reportIsolatedChanges(cwd, sessionId, worktree, agentId) { const committed = commitSessionWork(worktree.path, `vantage: ${agentId} session ${sessionId}`); if (!committed) { removeSessionWorktree(cwd, worktree.path, worktree.branch); return { files: [], added: 0, removed: 0, patch: "" }; } const diff = sessionDiff(worktree.path, worktree.baseSha); fs.writeFileSync(path.join(sessionDir(cwd, sessionId), "changes.patch"), diff.patch); return diff; } function printIsolatedChanges(sessionId, worktree, diff) { if (diff.files.length === 0) { log("isolation: no changes made — removed the empty worktree/branch"); return; } log(`isolation: ${diff.files.length} file(s) changed, +${diff.added}/-${diff.removed} on ${worktree.branch}`); for (const f of diff.files.slice(0, 10)) log(fmtFileLine(f)); if (diff.files.length > 10) log(` … and ${diff.files.length - 10} more`); log(`review: vantage review ${sessionId}`); log(`merge: git merge --no-ff ${worktree.branch}`); log(`discard: vantage discard ${sessionId}`); } function policyToEnv(policy) { return Object.entries(policy).map(([k, v]) => `${k}:${v}`).join(","); }