mirror of
https://github.com/herdrdev/herdr.git
synced 2026-09-22 00:01:06 +00:00
* 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>
211 lines
5.8 KiB
JavaScript
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() {},
|
|
};
|