openlatch-client 0.6.13

OpenLatch runtime enforcement node — the capture-and-enforce adapter that evaluates every covered action against a coding agent's Autonomy Zone before it runs
// OpenLatch — execution control for OpenCode. Managed by `openlatch init`; edits are healed.
//
// A thin shim with no imports and no policy logic: it spawns `openlatch-hook`,
// hands it one JSON payload on stdin and reads one line back. Only a Block
// verdict ever throws; every error in our own path answers Allow.
const OL_DIR = __OPENLATCH_DIR__;
const BUDGET_MS = 1500;
const TOAST_MS = 300;
const CAP_BYTES = __CAP_BYTES__;
const WIN = process.platform === "win32";
const HOOK = OL_DIR + (WIN ? "\\bin\\openlatch-hook.exe" : "/bin/openlatch-hook");
const TRUNCATE_ORDER = ["output", "args", "prompt"];
const SEEN = Symbol.for("openlatch.opencode.load-report");

const enc = new TextEncoder();
const size = (value) => enc.encode(JSON.stringify(value)).length;

// The longest string inside a value, as [holder, key] so it can be replaced.
function longest(holder, key, best) {
  const v = holder[key];
  if (typeof v === "string") {
    if (!best || v.length > best[0][best[1]].length) return [holder, key];
    return best;
  }
  if (v && typeof v === "object") {
    for (const k of Object.keys(v)) best = longest(v, k, best);
  }
  return best;
}

// Shorten the largest of `output`, `args` and `prompt`, in that order, until the
// whole serialized payload — the `truncated` list included — fits the cap. It
// works on a detached copy: `args` is OpenCode's live `output.args`, and the
// call it describes must run with the arguments the developer's agent gave.
function fit(payload) {
  if (size(payload) <= CAP_BYTES) return payload;
  const copy = JSON.parse(JSON.stringify(payload));
  const cut = [];
  for (const field of TRUNCATE_ORDER) {
    while (size(copy) > CAP_BYTES) {
      const target = longest(copy, field, null);
      if (!target || target[0][target[1]].length === 0) break;
      const s = target[0][target[1]];
      target[0][target[1]] = s.slice(0, Math.floor(s.length / 2));
      if (!cut.includes(field)) cut.push(field);
      copy.truncated = cut;
    }
    if (size(copy) <= CAP_BYTES) break;
  }
  return copy;
}

// The first line of a stream, then cancel it: a descendant holding the pipe
// open must never hold us.
async function firstLine(stream) {
  const reader = stream.getReader();
  const dec = new TextDecoder();
  let text = "";
  try {
    for (;;) {
      const { value, done } = await reader.read();
      if (done) break;
      text += dec.decode(value, { stream: true });
      const nl = text.indexOf("\n");
      if (nl >= 0) {
        text = text.slice(0, nl);
        break;
      }
    }
  } finally {
    // cancel() returns a promise: its rejection is handled here too, so an
    // errored stdout never escapes into OpenCode as an unhandled rejection.
    try { Promise.resolve(reader.cancel()).catch(() => {}); } catch {}
  }
  return text.trim();
}

// Spawn the hook. Returns the parsed verdict, or null for anything that is not
// a clean answer inside the budget. Never throws.
async function call(event, payload, captureOnly) {
  let proc = null;
  let timer = null;
  try {
    const argv = [HOOK, "--agent", "opencode", "--event", event, "--openlatch-dir", OL_DIR,
                  "--plugin-host-pid", String(process.pid)];
    if (captureOnly) argv.push("--capture-only");
    const body = JSON.stringify(fit(payload));
    // A record is not waited for, so its payload is written into the pipe
    // before we return: a Blob is written later, from the event loop, and is
    // lost when OpenCode exits right after the event (`session.idle` at the
    // end of `opencode run`), leaving the hook an empty stdin.
    proc = Bun.spawn(argv, {
      stdin: captureOnly ? "pipe" : new Blob([body]),
      stdout: captureOnly ? "ignore" : "pipe",
      stderr: "ignore",
      windowsHide: true,
    });
    if (captureOnly) {
      proc.stdin.write(body);
      proc.stdin.end();
      return null;
    }
    const answer = Promise.all([firstLine(proc.stdout), proc.exited]);
    const budget = new Promise((resolve) => { timer = setTimeout(() => resolve(null), BUDGET_MS); });
    const done = await Promise.race([answer, budget]);
    if (!done) {
      try { proc.kill("SIGKILL"); } catch {}
      return null;
    }
    const [line, code] = done;
    if (code !== 0 || !line) return null;
    const verdict = JSON.parse(line);
    return verdict && typeof verdict === "object" ? verdict : null;
  } catch {
    try { if (proc) proc.kill("SIGKILL"); } catch {}
    return null;
  } finally {
    if (timer) clearTimeout(timer);
  }
}

const isBlock = (v) => !!v && v.decision === "block" && typeof v.reason === "string" && v.reason.length > 0;

const LIFECYCLE = { "session.created": "session_start", "session.idle": "stop", "session.deleted": "session_end" };

export default async function openlatch(ctx) {
  let mcpServers = [];
  // Read at each call, never at load: a host may evaluate this module before
  // it sets OPENCODE_CLIENT (observed under `opencode acp`).
  const common = (hook, sessionId) => ({
    opencode_hook: hook,
    session_id: sessionId,
    directory: ctx.directory,
    worktree: ctx.worktree,
    project_id: ctx.project ? ctx.project.id : undefined,
    opencode_client: process.env.OPENCODE_CLIENT || "cli",
    mcp_servers: mcpServers,
  });
  const loadReport = (stage, cfg) => {
    try {
      const seen = (globalThis[SEEN] = globalThis[SEEN] || {});
      if (seen[stage]) return;
      seen[stage] = true;
      call("plugin_loaded", {
        opencode_hook: "plugin_loaded",
        opencode_client: process.env.OPENCODE_CLIENT || "cli",
        directory: ctx.directory,
        worktree: ctx.worktree,
        project_id: ctx.project ? ctx.project.id : undefined,
        mcp_servers: mcpServers,
        // Only the config stage knows the effective list; the module stage
        // omits the field so the daemon keeps the last list it was given.
        plugins: cfg ? (Array.isArray(cfg.plugin) ? cfg.plugin.map(String) : []) : undefined,
      }, true);
    } catch {}
  };
  loadReport("module", undefined);

  return {
    config: async (cfg) => {
      try {
        if (cfg && cfg.mcp && typeof cfg.mcp === "object") mcpServers = Object.keys(cfg.mcp);
        loadReport("config", cfg);
      } catch {}
    },
    "tool.execute.before": async (input, output) => {
      const verdict = await call("pre_tool_use", {
        ...common("tool.execute.before", input && input.sessionID),
        call_id: input && input.callID,
        tool: input && input.tool,
        args: output && output.args,
      }, false);
      if (isBlock(verdict)) throw new Error(verdict.reason);
    },
    "chat.message": async (input, output) => {
      let verdict = null;
      try {
        const message = (output && output.message) || {};
        const model = (input && input.model) || message.model;
        const parts = (output && Array.isArray(output.parts)) ? output.parts : [];
        verdict = await call("user_prompt_submit", {
          ...common("chat.message", input && input.sessionID),
          message_id: message.id || (input && input.messageID),
          agent: message.agent || (input && input.agent),
          model: model && model.providerID ? `${model.providerID}/${model.modelID}` : undefined,
          prompt: parts.filter((p) => p && p.type === "text").map((p) => p.text).join("\n"),
        }, false);
      } catch {
        verdict = null;
      }
      if (isBlock(verdict)) {
        try {
          await Promise.race([
            ctx.client.tui.showToast({ body: { variant: "error", message: verdict.reason } }),
            new Promise((resolve) => setTimeout(resolve, TOAST_MS)),
          ]);
        } catch {}
        throw new Error(verdict.reason);
      }
    },
    "tool.execute.after": async (input, output) => {
      try {
        call("post_tool_use", {
          ...common("tool.execute.after", input && input.sessionID),
          call_id: input && input.callID,
          tool: input && input.tool,
          args: input && input.args,
          output: output && output.output,
          title: output && output.title,
        }, true);
      } catch {}
    },
    event: async ({ event }) => {
      try {
        const wire = event && LIFECYCLE[event.type];
        if (!wire) return;
        const props = event.properties || {};
        const sessionId = props.sessionID || (props.info && props.info.id);
        if (!sessionId) return;
        const payload = common(event.type, sessionId);
        if (wire === "session_start" && props.info && typeof props.info.parentID === "string") {
          payload.parent_session_id = props.info.parentID;
        }
        call(wire, payload, true);
      } catch {}
    },
  };
}