@jovan158/vantage
Version:
See and control what your coding agent does
123 lines (122 loc) • 5.16 kB
JavaScript
// 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;
}