UNPKG

@jovan158/vantage

Version:

See and control what your coding agent does

123 lines (122 loc) • 5.16 kB
// How each agent talks to its hooks, and how a hook finds its session. // // Every agent that can stop a tool call runs a command before it and reads // the command's answer — each in its own dialect. `vantage hook <agent>` // reads the agent's JSON from stdin, turns it into one HookCall, lets the // shared decision logic (src/hook.ts) judge it, and answers in the agent's // dialect again. // // What the hook needs to know about the session (policy, rules file, budget, // alerts waiting) is in a small JSON file written by `vantage run`. Its path // is an argument of the hook command, which Vantage registers with the agent // for that one session. import fs from "node:fs"; import path from "node:path"; export function writeHookConfig(file, config) { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, JSON.stringify(config, null, 2)); } export function readHookConfig(file) { if (!file) return null; try { const json = JSON.parse(fs.readFileSync(file, "utf8")); if (!json || typeof json !== "object") return null; const out = {}; for (const [k, v] of Object.entries(json)) if (k.startsWith("VANTAGE_") && typeof v === "string") out[k] = v; return out; } catch { return null; } } function json(raw) { try { const v = JSON.parse(raw); return v && typeof v === "object" && !Array.isArray(v) ? v : null; } catch { return null; } } function obj(v) { if (v && typeof v === "object" && !Array.isArray(v)) return v; if (typeof v === "string") { const parsed = json(v); if (parsed) return parsed; } return undefined; } function str(v) { return typeof v === "string" && v ? v : undefined; } const out = (value, exitCode = 0) => ({ stdout: value ? JSON.stringify(value) : "", exitCode }); // Claude Code's contract, which Codex, Copilot CLI (with PascalCase event // names) and Vantage's own OpenCode and pi extensions speak too. // stdin : { hook_event_name, tool_name, tool_input, cwd, ... } // stdout: { hookSpecificOutput: { hookEventName, permissionDecision, // permissionDecisionReason }, systemMessage } function claudeParse(raw) { const j = json(raw); if (!j) return null; const name = str(j.hook_event_name) ?? str(j.hookEventName); const cwd = str(j.cwd); if (name === "Stop" || name === "SubagentStop") return { event: "stop", cwd }; if (name === "SessionStart") return { event: "start", cwd }; const tool = str(j.tool_name) ?? str(j.toolName); if (!tool) return name ? { event: "other", cwd } : null; return { event: "tool", tool, input: obj(j.tool_input) ?? obj(j.toolArgs), cwd }; } function claudeReply(call, a, extra = {}) { const message = a.alerts ? { systemMessage: a.alerts } : {}; if (call.event === "start") { return out(a.context ? { hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: a.context } } : null); } if (call.event !== "tool" || !a.decision) return out(a.alerts ? message : null); return out({ ...extra, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: a.decision, permissionDecisionReason: a.reason }, ...message, }); } const claude = { ask: "agent", parse: claudeParse, reply: (c, a) => claudeReply(c, a) }; // Codex implements Claude's contract, but a PreToolUse hook can only block: // "ask" is refused as unsupported (and would let the call through). const codex = { ask: "none", parse: claudeParse, reply: (c, a) => claudeReply(c, a) }; // Copilot CLI: hooks registered under the PascalCase names get Claude's // payload and tool names. Its documented answer puts the decision at the top // level; the Claude shape is sent alongside for the same reason. Messages // for the user go in "progress" lines before the answer, which it shows in // its timeline. const copilot = { ask: "agent", parse: claudeParse, reply(c, a) { const progress = a.alerts ? a.alerts.split("\n").map((message) => JSON.stringify({ type: "progress", message })) : []; const decision = c.event === "tool" && a.decision ? claudeReply(c, { ...a, alerts: "" }, { permissionDecision: a.decision, permissionDecisionReason: a.reason }).stdout : ""; return { stdout: [...progress, decision].filter(Boolean).join("\n"), exitCode: 0 }; }, }; // OpenCode: Vantage's plugin speaks Claude's contract. A plugin can stop a // tool call but not ask about it; OpenCode's own permission settings could, // but a rule added there would also override the user's own "deny" for the // same call — so "ask" blocks, as with Codex. const opencode = { ask: "none", parse: claudeParse, reply: (c, a) => claudeReply(c, a) }; // pi: Vantage's extension speaks Claude's contract and asks through pi's UI. const pi = claude; const PROTOCOLS = { claude, codex, copilot, opencode, pi }; export function hookProtocol(agent) { return PROTOCOLS[agent ?? "claude"] ?? claude; }