diff options
| author | Mark Jaquith <mark.jaquith@gusto.com> | 2026-09-11 16:36:18 -0400 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-11 22:36:18 +0200 |
| commit | fb5525fc2234a246dfdfc9e0d7ed73c824cbd8a6 (patch) | |
| tree | f989f8175e8afdd89824fd94477b1b1645af05ff /src/integration/assets/opencode/herdr-tui-session.js | |
| parent | 38d002de59331b27aee2f5838639ea27a04878f8 (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.js | 243 |
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(); + }; +} |
