aboutsummaryrefslogtreecommitdiff
path: root/src/integration/assets/opencode/herdr-tui-session.js
diff options
context:
space:
mode:
authorMark Jaquith <mark.jaquith@gusto.com>2026-09-11 16:36:18 -0400
committerGitHub <noreply@github.com>2026-09-11 22:36:18 +0200
commitfb5525fc2234a246dfdfc9e0d7ed73c824cbd8a6 (patch)
treef989f8175e8afdd89824fd94477b1b1645af05ff /src/integration/assets/opencode/herdr-tui-session.js
parent38d002de59331b27aee2f5838639ea27a04878f8 (diff)
fix: opencode v2 lifecycle reporting (#3757)
* fix: support opencode v2 lifecycle reporting * fix: repair Japanese docs * fix: ignore payload-less OpenCode events * fix: harden opencode v2 integration install and reporting Follow-ups on top of the V2 lifecycle support: - create `cli.json` when OpenCode has no V1 TUI preferences (`tui.json` or `kv.json`) to migrate, instead of only registering into an existing file - settle stalled socket attempts with a plain connect timer, and resend the latest lifecycle state after a failed delivery so the pane cannot get stuck - resolve the OpenCode state directory from `XDG_STATE_HOME` - remove the managed `herdr-opencode` directory on uninstall - document failed executions reporting `blocked`, keep the integration test environment independent of an inherited `XDG_STATE_HOME`, and simplify `reconcileBlockers` to reassign its map --------- Co-authored-by: Jonathan Liebig <jonathan.liebig@gmail.com>
Diffstat (limited to 'src/integration/assets/opencode/herdr-tui-session.js')
-rw-r--r--src/integration/assets/opencode/herdr-tui-session.js243
1 files changed, 229 insertions, 14 deletions
diff --git a/src/integration/assets/opencode/herdr-tui-session.js b/src/integration/assets/opencode/herdr-tui-session.js
index f4f08d57..4e5631ac 100644
--- a/src/integration/assets/opencode/herdr-tui-session.js
+++ b/src/integration/assets/opencode/herdr-tui-session.js
@@ -1,7 +1,7 @@
// installed by herdr
// managed by herdr; reinstalling or updating the integration overwrites this file.
// HERDR_INTEGRATION_ID=opencode-tui
-// HERDR_INTEGRATION_VERSION=11
+// HERDR_INTEGRATION_VERSION=12
import net from "node:net";
@@ -10,11 +10,11 @@ const AGENT = "opencode";
const ROUTE_POLL_INTERVAL_MS = 100;
const SELECTION_RETRY_DELAYS_MS = [100, 400, 1_000];
-function requestOnce(sessionID) {
+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();
+ return Promise.resolve(true);
}
const socketEndpoint =
@@ -23,35 +23,50 @@ function requestOnce(sessionID) {
id: `${SOURCE}:tui:${Date.now()}:${Math.floor(Math.random() * 1_000_000)
.toString()
.padStart(6, "0")}`,
- method: "pane.report_agent_session",
+ method: state === undefined ? "pane.report_agent_session" : "pane.report_agent",
params: {
pane_id: paneId,
source: SOURCE,
agent: AGENT,
agent_session_id: sessionID,
- session_start_source: "select",
+ ...(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`);
});
- 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);
+ // 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" ||
@@ -111,3 +126,203 @@ export default {
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();
+ };
+}