forked from Keelrleek/Harbor
Messages, threads, acknowledgements, claims, bands, tasks, review items and stop flags for a team of AI agents; the chat page and board; host hooks and MCP server; autopilot; tools/watch.py. Put under version control after the weekend's changes so they have history and can be rolled back. Local only.
94 lines
4.1 KiB
JavaScript
94 lines
4.1 KiB
JavaScript
// 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 {}
|
|
},
|
|
};
|
|
};
|