Files
herdr/src/integration/assets/opencode/herdr-agent-state.js
T
Mark JaquithandJonathan Liebig fb5525fc22 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>
2026-09-11 22:36:18 +02:00

211 lines
5.8 KiB
JavaScript

// 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=12
import net from "node:net";
const SOURCE = "herdr:opencode";
const AGENT = "opencode";
let reportSeq = Date.now() * 1000;
let requestChain = Promise.resolve();
let reportedRootSessionID;
// Track child sessions so their events cannot replace the pane's root session.
// User prompts carry the root id to preserve its identity and cross-talk guard.
const childSessions = new Map();
const CHILD_EVENT_STATES = new Map([
["permission.asked", "blocked"],
["question.asked", "blocked"],
["permission.replied", "working"],
["question.replied", "working"],
["question.rejected", "working"],
]);
function nextReportSeq() {
reportSeq += 1;
return reportSeq;
}
function sessionIDFromProperties(properties) {
return typeof properties?.sessionID === "string" && properties.sessionID
? properties.sessionID
: undefined;
}
const SESSION_STATE_BY_STATUS = new Map([
["idle", "idle"],
["active", "working"],
["busy", "working"],
["pending", "working"],
["retry", "working"],
["running", "working"],
["streaming", "working"],
["working", "working"],
]);
function stateFromSessionStatus(status) {
const kind = typeof status === "string" ? status : status?.type;
return typeof kind === "string"
? SESSION_STATE_BY_STATUS.get(kind.toLowerCase())
: undefined;
}
function request(method, params) {
const pending = requestChain.then(() => requestOnce(method, params));
requestChain = pending.catch(() => {});
return pending;
}
function requestOnce(method, params) {
const paneId = process.env.HERDR_PANE_ID;
const socketPath = process.env.HERDR_SOCKET_PATH;
if (!paneId || !socketPath) {
return Promise.resolve();
}
const socketEndpoint =
process.platform === "win32" ? `\\\\.\\pipe\\${socketPath}` : socketPath;
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(socketEndpoint, () => {
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) {
if (!sessionID) {
return Promise.resolve();
}
return request("pane.report_agent_session", { agent_session_id: sessionID });
}
function reportState(state, sessionID) {
const params = { state };
if (sessionID) {
reportedRootSessionID = 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.set(info.id, info.parentID);
}
if (sessionID && childSessions.has(sessionID)) {
const state = CHILD_EVENT_STATES.get(type);
if (state) {
let rootSessionID = sessionID;
while (childSessions.has(rootSessionID)) {
rootSessionID = childSessions.get(rootSessionID);
}
await reportState(state, rootSessionID);
}
return;
}
switch (type) {
case "session.created":
// Creation is server-global, so an attached client may own it. The
// TUI plugin separately reports the root selected in this pane.
reportedRootSessionID = sessionID;
break;
case "session.updated":
if (sessionID && sessionID !== reportedRootSessionID) {
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;
}
},
};
};
// V1 (1.18.29+) calls server(). V2 calls setup() instead. Its shared server
// cannot attribute sessions using its process environment: the pane-local TUI
// owns both selection and lifecycle reporting there, including remote servers.
export default {
id: "herdr.opencode",
server: HerdrAgentStatePlugin,
setup() {},
};