diff --git a/.config/opencode/plugins/herdr-agent-state.js b/.config/opencode/plugins/herdr-agent-state.js new file mode 100644 index 0000000..9a14b3c --- /dev/null +++ b/.config/opencode/plugins/herdr-agent-state.js @@ -0,0 +1,197 @@ +// installed by herdr +// managed by herdr; reinstalling or updating the integration overwrites this file. +// add custom hooks/plugins beside this file instead of editing it. +// HERDR_INTEGRATION_ID=opencode +// HERDR_INTEGRATION_VERSION=8 + +import net from "node:net"; + +const SOURCE = "herdr:opencode"; +const AGENT = "opencode"; +let reportSeq = Date.now() * 1000; + +// Subagent (task tool) sessions carry a parentID; the main agent session does +// not. Their lifecycle events would otherwise clobber the pane's real state, so +// learn child session ids from session.created/updated and drop their reports. +const childSessions = new Set(); + +function nextReportSeq() { + reportSeq += 1; + return reportSeq; +} + +function sessionIDFromProperties(properties) { + return typeof properties?.sessionID === "string" && properties.sessionID + ? properties.sessionID + : undefined; +} + +function stateFromSessionStatus(status) { + // session.status carries { type: "idle" | "busy" | "retry" }; older builds used a bare string. + const kind = typeof status === "string" ? status : status?.type; + if (typeof kind !== "string") return undefined; + switch (kind.toLowerCase()) { + case "idle": + return "idle"; + case "active": + case "busy": + case "pending": + case "running": + case "streaming": + case "working": + case "retry": + return "working"; + default: + return undefined; + } +} + +function request(method, params) { + const paneId = process.env.HERDR_PANE_ID; + const socketPath = process.env.HERDR_SOCKET_PATH; + + if (!paneId || !socketPath) { + return Promise.resolve(); + } + + const requestId = `${SOURCE}:${Date.now()}:${Math.floor(Math.random() * 1_000_000) + .toString() + .padStart(6, "0")}`; + const request = { + id: requestId, + method, + params: { + pane_id: paneId, + source: SOURCE, + agent: AGENT, + seq: nextReportSeq(), + ...params, + }, + }; + + return new Promise((resolve) => { + const client = net.createConnection(socketPath, () => { + client.write(`${JSON.stringify(request)}\n`); + }); + + const finish = () => { + client.destroy(); + resolve(); + }; + + client.setTimeout(500, finish); + client.on("data", finish); + client.on("error", finish); + client.on("end", finish); + client.on("close", resolve); + }); +} + +function reportSession(sessionID, sessionStartSource) { + if (!sessionID) { + return Promise.resolve(); + } + const params = { agent_session_id: sessionID }; + if (sessionStartSource) { + params.session_start_source = sessionStartSource; + } + return request("pane.report_agent_session", params); +} + +function reportState(state, sessionID) { + const params = { state }; + if (sessionID) { + params.agent_session_id = sessionID; + } + return request("pane.report_agent", params); +} + +export const HerdrAgentStatePlugin = async () => { + if ( + process.env.HERDR_ENV !== "1" || + !process.env.HERDR_SOCKET_PATH || + !process.env.HERDR_PANE_ID + ) { + return {}; + } + + return { + "chat.message": async ({ sessionID }) => { + if (sessionID && childSessions.has(sessionID)) { + return; + } + await reportState("working", sessionID); + }, + event: async ({ event }) => { + const type = event?.type; + const properties = event?.properties ?? {}; + const sessionID = sessionIDFromProperties(properties); + + const info = properties.info; + if (info?.id && info.parentID) { + childSessions.add(info.id); + } + if (sessionID && childSessions.has(sessionID)) { + // Child session events are dropped so they cannot clobber the pane's + // root-agent state, but a subagent waiting on the user must still + // surface as blocked (and clear once answered). Report state only, + // without an agent_session_id, so the pane keeps the root session. + switch (type) { + case "permission.asked": + case "question.asked": + await reportState("blocked"); + break; + case "permission.replied": + case "question.replied": + case "question.rejected": + await reportState("working"); + break; + default: + break; + } + return; + } + + switch (type) { + case "session.created": + // A root session.created is a genuine new-session start (subagent + // creates are dropped above). Signal it so herdr replaces the pane's + // prior session id instead of treating the change as cross-talk. + await reportSession(sessionID, "new"); + break; + case "session.updated": + await reportSession(sessionID); + break; + case "session.status": { + const state = stateFromSessionStatus(properties.status); + if (state) { + await reportState(state, sessionID); + } else { + await reportSession(sessionID); + } + break; + } + case "tool.execute.before": + case "tool.execute.after": + case "permission.replied": + case "question.replied": + case "question.rejected": + case "session.compacted": + await reportState("working", sessionID); + break; + case "permission.asked": + case "question.asked": + case "session.error": + await reportState("blocked", sessionID); + break; + case "session.idle": + await reportState("idle", sessionID); + break; + case "session.deleted": + break; + default: + break; + } + }, + }; +};