fix: scope opencode hook to main agent and adopt new sessions

refs #765
This commit is contained in:
Ogulcan Celik 2026-06-24 00:52:07 +03:00
parent 64a12de1cd
commit 74fa90cecc
4 changed files with 101 additions and 6 deletions

View File

@ -71,7 +71,9 @@ pub fn session_ref_from_report(
pub fn normalize_session_start_source(value: Option<String>) -> Option<String> {
match value.as_deref().map(str::trim) {
Some(source @ ("startup" | "resume" | "clear" | "compact")) => Some(source.to_string()),
Some(source @ ("startup" | "resume" | "clear" | "compact" | "new")) => {
Some(source.to_string())
}
_ => None,
}
}
@ -518,6 +520,10 @@ mod tests {
normalize_session_start_source(Some("compact".into())),
Some("compact".into())
);
assert_eq!(
normalize_session_start_source(Some("new".into())),
Some("new".into())
);
assert_eq!(
normalize_session_start_source(Some(" resume ".into())),
Some("resume".into())

View File

@ -2,7 +2,7 @@
// 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=6
// HERDR_INTEGRATION_VERSION=7
import net from "node:net";
@ -10,6 +10,11 @@ const SOURCE = "herdr:opencode";
const AGENT = "opencode";
let reportSeq = Date.now() * 1000;
// Subagent (task tool) sessions carry a parentID; the main agent session does
// not. Their lifecycle events would otherwise clobber the pane's real state, so
// learn child session ids from session.created/updated and drop their reports.
const childSessions = new Set();
function nextReportSeq() {
reportSeq += 1;
return reportSeq;
@ -81,11 +86,15 @@ function request(method, params) {
});
}
function reportSession(sessionID) {
function reportSession(sessionID, sessionStartSource) {
if (!sessionID) {
return Promise.resolve();
}
return request("pane.report_agent_session", { agent_session_id: sessionID });
const params = { agent_session_id: sessionID };
if (sessionStartSource) {
params.session_start_source = sessionStartSource;
}
return request("pane.report_agent_session", params);
}
function reportState(state, sessionID) {
@ -107,6 +116,9 @@ export const HerdrAgentStatePlugin = async () => {
return {
"chat.message": async ({ sessionID }) => {
if (sessionID && childSessions.has(sessionID)) {
return;
}
await reportState("working", sessionID);
},
event: async ({ event }) => {
@ -114,8 +126,21 @@ export const HerdrAgentStatePlugin = async () => {
const properties = event?.properties ?? {};
const sessionID = sessionIDFromProperties(properties);
const info = properties.info;
if (info?.id && info.parentID) {
childSessions.add(info.id);
}
if (sessionID && childSessions.has(sessionID)) {
return;
}
switch (type) {
case "session.created":
// A root session.created is a genuine new-session start (subagent
// creates are dropped above). Signal it so herdr replaces the pane's
// prior session id instead of treating the change as cross-talk.
await reportSession(sessionID, "new");
break;
case "session.updated":
await reportSession(sessionID);
break;

View File

@ -135,7 +135,7 @@ const DROID_REMOVED_LIFECYCLE_HOOK_EVENTS: [(&str, &str); 9] = [
];
const OPENCODE_PLUGIN_INSTALL_NAME: &str = "herdr-agent-state.js";
const OPENCODE_PLUGIN_ASSET: &str = include_str!("assets/opencode/herdr-agent-state.js");
const OPENCODE_INTEGRATION_VERSION: u32 = 6;
const OPENCODE_INTEGRATION_VERSION: u32 = 7;
const KILO_PLUGIN_INSTALL_NAME: &str = "herdr-agent-state.js";
const KILO_PLUGIN_ASSET: &str = include_str!("assets/kilo/herdr-agent-state.js");
const KILO_INTEGRATION_VERSION: u32 = 2;

View File

@ -837,7 +837,7 @@ impl TerminalState {
"herdr:codex",
"codex",
Some("startup" | "clear" | "resume" | "compact")
)
) | ("herdr:opencode", "opencode", Some("new"))
)
}
@ -3268,6 +3268,70 @@ mod tests {
}
}
#[test]
fn opencode_new_session_ref_replaces_existing_session_ref() {
let mut terminal = test_terminal();
terminal
.set_agent_session_ref(
"herdr:opencode".into(),
"opencode".into(),
crate::agent_resume::AgentSessionRef::id("opencode-old"),
Some(20),
)
.expect("initial session should be accepted");
let mutation = terminal
.set_agent_session_ref_for_session_start(
"herdr:opencode".into(),
"opencode".into(),
crate::agent_resume::AgentSessionRef::id("opencode-new"),
Some(21),
Some("new".into()),
)
.expect("new should replace the session");
assert!(mutation.session_ref_changed);
assert_eq!(
terminal
.persisted_agent_session
.as_ref()
.map(|session| session.session_ref.value.as_str()),
Some("opencode-new")
);
}
#[test]
fn opencode_session_ref_without_start_source_does_not_replace_existing() {
let mut terminal = test_terminal();
terminal
.set_agent_session_ref(
"herdr:opencode".into(),
"opencode".into(),
crate::agent_resume::AgentSessionRef::id("opencode-old"),
Some(20),
)
.expect("initial session should be accepted");
// session.updated reports carry no session_start_source, so a different
// id must not displace the established session (cross-talk guard).
let mutation = terminal.set_agent_session_ref_for_session_start(
"herdr:opencode".into(),
"opencode".into(),
crate::agent_resume::AgentSessionRef::id("opencode-other"),
Some(21),
None,
);
assert!(mutation.is_none());
assert_eq!(
terminal
.persisted_agent_session
.as_ref()
.map(|session| session.session_ref.value.as_str()),
Some("opencode-old")
);
}
#[test]
fn different_owner_session_ref_does_not_replace_existing_session_ref() {
let mut terminal = test_terminal();