// installed by herdr // managed by herdr; reinstalling or updating the integration overwrites this file. // HERDR_INTEGRATION_ID=opencode-tui // HERDR_INTEGRATION_VERSION=12 import net from "node:net"; const SOURCE = "herdr:opencode"; const AGENT = "opencode"; const ROUTE_POLL_INTERVAL_MS = 100; const SELECTION_RETRY_DELAYS_MS = [100, 400, 1_000]; function requestOnce(sessionID, state, seq, isCurrent = () => true) { const paneId = process.env.HERDR_PANE_ID; const socketPath = process.env.HERDR_SOCKET_PATH; if (!paneId || !socketPath) { return Promise.resolve(true); } const socketEndpoint = process.platform === "win32" ? `\\\\.\\pipe\\${socketPath}` : socketPath; const request = { id: `${SOURCE}:tui:${Date.now()}:${Math.floor(Math.random() * 1_000_000) .toString() .padStart(6, "0")}`, method: state === undefined ? "pane.report_agent_session" : "pane.report_agent", params: { pane_id: paneId, source: SOURCE, agent: AGENT, agent_session_id: sessionID, ...(state === undefined ? { session_start_source: "select" } : { state, seq }), }, }; return new Promise((resolve) => { let settled = false; let timer; const settle = (delivered) => { if (settled) return; settled = true; clearTimeout(timer); client.destroy(); resolve(delivered); }; const client = net.createConnection(socketEndpoint, () => { if (!isCurrent()) { settle(false); return; } client.write(`${JSON.stringify(request)}\n`); }); // A plain timer, not socket.setTimeout, so a connection that never finishes // connecting still settles and cannot block later reports behind the queue. timer = setTimeout(() => settle(false), 500); timer.unref?.(); client.on("data", () => settle(true)); client.on("error", () => settle(false)); client.on("end", () => settle(false)); client.on("close", () => settle(false)); }); } export default { id: "herdr.opencode.session-selection", // Keep this plain object dependency-free: V1 and V2 expose different SDK // packages, but both loaders accept their own lifecycle entry on this object. setup, tui: async (api) => { if ( process.env.HERDR_ENV !== "1" || !process.env.HERDR_SOCKET_PATH || !process.env.HERDR_PANE_ID ) { return; } let selectedSessionID; let retryIndex = 0; let nextReportAt = 0; let reportPending = false; const syncSelectedSession = async () => { const route = api.route.current; const sessionID = route?.name === "session" ? route.params?.sessionID : undefined; const session = typeof sessionID === "string" && sessionID ? api.state.session.get(sessionID) : undefined; if (!session || session.parentID) { selectedSessionID = undefined; retryIndex = 0; nextReportAt = 0; return; } if (sessionID !== selectedSessionID) { selectedSessionID = sessionID; retryIndex = 0; nextReportAt = 0; } if (reportPending || Date.now() < nextReportAt) { return; } const reportingSessionID = sessionID; reportPending = true; try { await requestOnce(reportingSessionID); } catch { // Best-effort reporting retries below while the selected route remains active. } finally { reportPending = false; } if (selectedSessionID !== reportingSessionID) { retryIndex = 0; nextReportAt = 0; return; } const retryDelay = SELECTION_RETRY_DELAYS_MS[retryIndex]; retryIndex += 1; nextReportAt = retryDelay === undefined ? Number.POSITIVE_INFINITY : Date.now() + retryDelay; }; await syncSelectedSession(); const routePoll = setInterval(() => void syncSelectedSession(), ROUTE_POLL_INTERVAL_MS); api.lifecycle.onDispose(() => clearInterval(routePoll)); }, }; function setup(api) { if (process.env.HERDR_ENV !== "1" || !process.env.HERDR_SOCKET_PATH || !process.env.HERDR_PANE_ID) return; let disposed = false; let selected; let generation = 0; let sequence = Date.now() * 1000; let chain = Promise.resolve(); let retryIndex = 0; let nextSelectionAt = 0; let state = "idle"; let retryTimer; const sessions = new Map(); let blockers = new Map(); // Event callbacks may precede cache updates. Retain each delta until the // cache reflects it, so late hydration cannot undo a reply or lose an ask. const blockerChanges = new Map(); function root(id) { const seen = new Set(); while (typeof id === "string" && !seen.has(id)) { seen.add(id); const session = api.data.session.get(id) ?? sessions.get(id); if (!session) return; if (!session.parentID) return id; id = session.parentID; } } function current() { const route = api.ui.router.current(); return route.type === "session" ? root(route.sessionID) : undefined; } // Selection and lifecycle use one queue. Recheck attribution at dispatch, // not just when receiving the event, and reject A -> B -> A stale work too. function enqueue(value) { const sessionID = selected; const revision = generation; const isCurrent = () => !disposed && revision === generation && !!sessionID && current() === sessionID; chain = chain.then(async () => { if (!isCurrent()) return; const delivered = await requestOnce(sessionID, value, value === undefined ? undefined : ++sequence, isCurrent); if (!delivered) scheduleStateRetry(); }).catch(() => {}); } // A dropped report must not strand the pane on a stale state once the // selection retry schedule has run out: resend the latest state until the // socket accepts it or the selection is no longer current. function scheduleStateRetry() { if (disposed || retryTimer) return; retryTimer = setTimeout(() => { retryTimer = undefined; publish(); }, 500); retryTimer.unref?.(); } function publish() { enqueue(blockers.size ? "blocked" : state); } function changeBlocker(id, kind, requestID, present) { if (typeof requestID !== "string") return; const key = `${kind}:${requestID}`; blockerChanges.set(key, { id, kind, present }); if (present) blockers.set(key, id); else blockers.delete(key); } function reconcileBlockers() { const next = new Map(); const hydrated = new Set(); const members = new Set([selected, ...api.data.session.family(selected), ...blockers.values()]); for (const member of members) { if (root(member) !== selected) continue; for (const kind of ["permission", "form"]) { const items = api.data.session[kind].list(member); if (items === undefined) { for (const [key, owner] of blockers) { if (owner === member && key.startsWith(`${kind}:`)) next.set(key, owner); } continue; } hydrated.add(`${kind}:${member}`); for (const item of items) next.set(`${kind}:${item.id}`, member); } } for (const [key, change] of blockerChanges) { if (hydrated.has(`${change.kind}:${change.id}`) && next.has(key) === change.present) { blockerChanges.delete(key); } else if (change.present) { next.set(key, change.id); } else { next.delete(key); } } const changed = (blockers.size > 0) !== (next.size > 0); blockers = next; return changed; } function syncSelection() { if (disposed) return; const id = current(); if (id !== selected) { selected = id; generation += 1; retryIndex = 0; nextSelectionAt = 0; blockers.clear(); blockerChanges.clear(); if (id) { state = api.data.session.status(id) === "running" ? "working" : "idle"; } } if (!id) return; const blockersChanged = reconcileBlockers(); if (Date.now() < nextSelectionAt) { if (blockersChanged) publish(); return; } enqueue(undefined); publish(); const delay = SELECTION_RETRY_DELAYS_MS[retryIndex++]; nextSelectionAt = delay === undefined ? Number.POSITIVE_INFINITY : Date.now() + delay; } function receive({ details: event }) { if (disposed) return; const data = event.data; if (data == null) return; if (event.type === "session.created") { sessions.set(data.sessionID, { id: data.sessionID, parentID: data.parentID }); } if (event.type === "session.deleted") { const affected = data.sessionID === selected || [...blockers.values()].includes(data.sessionID); sessions.delete(data.sessionID); // Deletion is delivered after the cache can remove the session. Use // stored ownership rather than looking up the deleted child's ancestry. for (const [key, owner] of blockers) if (owner === data.sessionID) blockers.delete(key); for (const [key, change] of blockerChanges) { if (change.id === data.sessionID) blockerChanges.delete(key); } syncSelection(); if (selected && affected) publish(); return; } syncSelection(); const id = event.type === "form.created" ? data.form.sessionID : data.sessionID; if (!selected || root(id) !== selected) return; switch (event.type) { case "permission.asked": changeBlocker(id, "permission", data.id, true); break; case "permission.replied": changeBlocker(id, "permission", data.requestID, false); break; case "form.created": changeBlocker(id, "form", data.form.id, true); break; case "form.replied": case "form.cancelled": changeBlocker(id, "form", data.id, false); break; case "session.execution.started": if (id !== selected) return; state = "working"; break; case "session.execution.succeeded": case "session.execution.interrupted": if (id !== selected) return; state = "idle"; break; case "session.execution.failed": if (id !== selected) return; state = "blocked"; break; default: return; } publish(); } const unsubscribe = api.data.listen(receive); syncSelection(); const poll = setInterval(syncSelection, ROUTE_POLL_INTERVAL_MS); return () => { disposed = true; generation += 1; clearTimeout(retryTimer); clearInterval(poll); unsubscribe(); sessions.clear(); blockers.clear(); blockerChanges.clear(); }; }