// Harbor for OpenCode: the STOP gate on every tool call, the brief at the start of each session, and a nudge into // an idle session when new Harbor messages arrive for this agent (one nudge per batch of messages, never while // stopped). Installed by `harbor install opencode`; the lines between the markers are filled in by the installer. import { spawn } from "node:child_process"; // >>> harbor settings >>> const AGENT = "OpenCode"; const PROJECT = null; const COMMAND = ["py", "-3.12", "-B", "R:/Workspace/Tools/Harbor/run.py"]; const WAKE_IDLE_SESSIONS = true; // check idle sessions every minute and nudge them when messages arrive // <<< harbor settings <<< function harbor(args, payload) { return new Promise((resolve) => { const argv = [...COMMAND.slice(1), "--as", AGENT, ...(PROJECT ? ["--project", PROJECT] : []), ...args]; let out = ""; let err = ""; let child; try { child = spawn(COMMAND[0], argv, { windowsHide: true }); } catch (error) { return resolve({ code: 0, out: "", err: String(error) }); } const timer = setTimeout(() => { try { child.kill(); } catch {} }, 20000); child.stdout.on("data", (d) => (out += d)); child.stderr.on("data", (d) => (err += d)); child.on("error", (error) => { clearTimeout(timer); resolve({ code: 0, out: "", err: String(error) }); }); child.on("close", (code) => { clearTimeout(timer); resolve({ code: code ?? 0, out, err }); }); child.stdin.end(JSON.stringify(payload ?? {})); }); } // The Python hook decides what a stopped agent may still run; the plugin only caches "allowed" briefly. export const HarborPlugin = async ({ client, directory }) => { let gate = { at: 0, blocked: false, reason: "" }; const idleSessions = new Set(); let checking = false; async function nudge(sessionID) { const r = await harbor(["hook", "idle", "--host", "opencode"], { cwd: directory }); let info = {}; try { info = JSON.parse(r.out || "{}"); } catch {} if (!info.prompt) return false; idleSessions.delete(sessionID); await client.session.promptAsync({ path: { id: sessionID }, body: { parts: [{ type: "text", text: info.prompt }] } }); return true; } if (WAKE_IDLE_SESSIONS) { setInterval(async () => { if (checking || idleSessions.size === 0) return; checking = true; try { const newest = [...idleSessions].pop(); await nudge(newest); } catch {} finally { checking = false; } }, 60000); } return { "tool.execute.before": async (input, output) => { const tool = input?.tool ?? ""; const args = output?.args ?? {}; const now = Date.now(); const edits = /edit|write|patch|apply|create|replace/i.test(String(tool)); if (gate.blocked || edits || now - gate.at > 2000 || /harbor/i.test(tool + JSON.stringify(args))) { const r = await harbor(["hook", "pre-tool", "--host", "opencode"], { cwd: directory, tool_name: tool, tool_input: args }); gate = { at: now, blocked: r.code === 2, reason: r.err.trim() }; } if (gate.blocked) throw new Error(gate.reason || "Harbor: STOP in force for this agent. Do not continue work."); }, event: async ({ event }) => { try { if (event?.type === "session.created") { const id = event.properties?.info?.id ?? event.properties?.sessionID; if (!id) return; const r = await harbor(["hook", "session-start", "--host", "generic"], { cwd: directory }); const text = r.out.trim(); if (text) await client.session.prompt({ path: { id }, body: { noReply: true, parts: [{ type: "text", text }] } }); } else if (event?.type === "session.idle") { const id = event.properties?.sessionID; if (!id) return; idleSessions.delete(id); idleSessions.add(id); if (!(await nudge(id))) await harbor(["hook", "turn-end", "--host", "opencode"], { cwd: directory }); } else if (event?.type === "session.status" && event.properties?.status?.type === "busy") { idleSessions.delete(event.properties?.sessionID); } } catch {} }, }; };