// 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; } }, }; };