aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/agent_resume.rs6
-rw-r--r--src/cli/integration.rs7
-rw-r--r--src/integration/actions.rs46
-rw-r--r--src/integration/assets/opencode/herdr-agent-state.js17
-rw-r--r--src/integration/assets/opencode/herdr-agent-state.test.ts31
-rw-r--r--src/integration/assets/opencode/herdr-tui-session.js113
-rw-r--r--src/integration/assets/opencode/herdr-tui-session.test.ts181
-rw-r--r--src/integration/mod.rs6
-rw-r--r--src/integration/opencode_config.rs298
-rw-r--r--src/integration/registry.rs22
-rw-r--r--src/integration/targets.rs51
-rw-r--r--src/integration/tests.rs115
-rw-r--r--src/integration/types.rs6
-rw-r--r--src/terminal/state.rs354
14 files changed, 1183 insertions, 70 deletions
diff --git a/src/agent_resume.rs b/src/agent_resume.rs
index ffbf4347..e2fc47e9 100644
--- a/src/agent_resume.rs
+++ b/src/agent_resume.rs
@@ -71,7 +71,7 @@ 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" | "new" | "fork")) => {
+ Some(source @ ("startup" | "resume" | "clear" | "compact" | "new" | "fork" | "select")) => {
Some(source.to_string())
}
_ => None,
@@ -609,6 +609,10 @@ mod tests {
Some("fork".into())
);
assert_eq!(
+ normalize_session_start_source(Some("select".into())),
+ Some("select".into())
+ );
+ assert_eq!(
normalize_session_start_source(Some(" resume ".into())),
Some("resume".into())
);
diff --git a/src/cli/integration.rs b/src/cli/integration.rs
index 236cebe7..27620590 100644
--- a/src/cli/integration.rs
+++ b/src/cli/integration.rs
@@ -47,6 +47,13 @@ fn integration_status(args: &[String]) -> std::io::Result<i32> {
crate::integration::IntegrationStatusKind::Current => {
format!("current ({version})")
}
+ crate::integration::IntegrationStatusKind::Outdated
+ if status
+ .installed_version
+ .is_some_and(|installed| installed >= status.expected_version) =>
+ {
+ format!("needs repair ({version})")
+ }
crate::integration::IntegrationStatusKind::Outdated => {
format!("outdated ({version} < v{})", status.expected_version)
}
diff --git a/src/integration/actions.rs b/src/integration/actions.rs
index 3cb73774..00bf8c8f 100644
--- a/src/integration/actions.rs
+++ b/src/integration/actions.rs
@@ -144,10 +144,20 @@ fn install_target_inner(target: crate::api::schema::IntegrationTarget) -> io::Re
}
crate::api::schema::IntegrationTarget::Opencode => {
let installed = install_opencode()?;
- vec![format!(
- "installed opencode integration plugin to {}",
- installed.plugin_path.display()
- )]
+ vec![
+ format!(
+ "installed opencode integration plugin to {}",
+ installed.plugin_path.display()
+ ),
+ format!(
+ "installed opencode tui integration plugin to {}",
+ installed.tui_plugin_path.display()
+ ),
+ format!(
+ "ensured opencode tui plugin config at {}",
+ installed.tui_config_path.display()
+ ),
+ ]
}
crate::api::schema::IntegrationTarget::Kilo => {
let installed = install_kilo()?;
@@ -451,17 +461,35 @@ pub(crate) fn uninstall_target(
}
crate::api::schema::IntegrationTarget::Opencode => {
let result = uninstall_opencode()?;
- if result.removed_plugin {
- vec![format!(
+ let mut messages = vec![if result.removed_plugin {
+ format!(
"removed opencode integration plugin at {}",
result.plugin_path.display()
- )]
+ )
} else {
- vec![format!(
+ format!(
"no opencode integration plugin found at {}",
result.plugin_path.display()
- )]
+ )
+ }];
+ messages.push(if result.removed_tui_plugin {
+ format!(
+ "removed opencode tui integration plugin at {}",
+ result.tui_plugin_path.display()
+ )
+ } else {
+ format!(
+ "no opencode tui integration plugin found at {}",
+ result.tui_plugin_path.display()
+ )
+ });
+ if result.updated_tui_config {
+ messages.push(format!(
+ "removed herdr opencode plugin entry from {}",
+ result.tui_config_path.display()
+ ));
}
+ messages
}
crate::api::schema::IntegrationTarget::Kilo => {
let result = uninstall_kilo()?;
diff --git a/src/integration/assets/opencode/herdr-agent-state.js b/src/integration/assets/opencode/herdr-agent-state.js
index 76265050..2840c221 100644
--- a/src/integration/assets/opencode/herdr-agent-state.js
+++ b/src/integration/assets/opencode/herdr-agent-state.js
@@ -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=9
+// HERDR_INTEGRATION_VERSION=10
import net from "node:net";
@@ -102,15 +102,11 @@ function requestOnce(method, params) {
});
}
-function reportSession(sessionID, sessionStartSource) {
+function reportSession(sessionID) {
if (!sessionID) {
return Promise.resolve();
}
- const params = { agent_session_id: sessionID };
- if (sessionStartSource) {
- params.session_start_source = sessionStartSource;
- }
- return request("pane.report_agent_session", params);
+ return request("pane.report_agent_session", { agent_session_id: sessionID });
}
function reportState(state, sessionID) {
@@ -157,10 +153,9 @@ export const HerdrAgentStatePlugin = async () => {
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");
+ // 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) {
diff --git a/src/integration/assets/opencode/herdr-agent-state.test.ts b/src/integration/assets/opencode/herdr-agent-state.test.ts
index be21fd71..6f8ee590 100644
--- a/src/integration/assets/opencode/herdr-agent-state.test.ts
+++ b/src/integration/assets/opencode/herdr-agent-state.test.ts
@@ -114,6 +114,37 @@ test("suppresses redundant same-session updates", async () => {
expect(requests.map(requestSessionID)).toEqual(["root-session", "replacement-session"]);
});
+test("does not classify server activity in another root session as a selection", async () => {
+ const plugin = await loadPlugin();
+
+ await plugin["chat.message"]({ sessionID: "visible-session" });
+ await plugin["chat.message"]({ sessionID: "attached-client-session" });
+
+ expect(requests.map(requestMethod)).toEqual([
+ "pane.report_agent",
+ "pane.report_agent",
+ ]);
+ expect(requests.map(requestSessionID)).toEqual([
+ "visible-session",
+ "attached-client-session",
+ ]);
+});
+
+test("does not classify server-global root creation as a local selection", async () => {
+ const plugin = await loadPlugin();
+
+ await plugin.event({
+ event: { type: "session.created", properties: { sessionID: "attached-session" } },
+ });
+ await plugin.event({
+ event: { type: "session.updated", properties: { sessionID: "attached-session" } },
+ });
+ await plugin["chat.message"]({ sessionID: "attached-session" });
+
+ expect(requests.map(requestMethod)).toEqual(["pane.report_agent"]);
+ expect(requests.map(requestSessionID)).toEqual(["attached-session"]);
+});
+
test("reports retry status as working", async () => {
const plugin = await loadPlugin();
diff --git a/src/integration/assets/opencode/herdr-tui-session.js b/src/integration/assets/opencode/herdr-tui-session.js
new file mode 100644
index 00000000..b5be5bbc
--- /dev/null
+++ b/src/integration/assets/opencode/herdr-tui-session.js
@@ -0,0 +1,113 @@
+// installed by herdr
+// managed by herdr; reinstalling or updating the integration overwrites this file.
+// HERDR_INTEGRATION_ID=opencode-tui
+// HERDR_INTEGRATION_VERSION=10
+
+import net from "node:net";
+
+const SOURCE = "herdr:opencode";
+const AGENT = "opencode";
+const ROUTE_POLL_INTERVAL_MS = 100;
+const SELECTION_RETRY_DELAYS_MS = [100, 400, 1_000];
+
+function requestOnce(sessionID) {
+ 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 request = {
+ id: `${SOURCE}:tui:${Date.now()}:${Math.floor(Math.random() * 1_000_000)
+ .toString()
+ .padStart(6, "0")}`,
+ method: "pane.report_agent_session",
+ params: {
+ pane_id: paneId,
+ source: SOURCE,
+ agent: AGENT,
+ agent_session_id: sessionID,
+ session_start_source: "select",
+ },
+ };
+
+ 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);
+ });
+}
+
+export default {
+ id: "herdr.opencode.session-selection",
+ tui: async (api) => {
+ if (
+ process.env.HERDR_ENV !== "1" ||
+ !process.env.HERDR_SOCKET_PATH ||
+ !process.env.HERDR_PANE_ID
+ ) {
+ return;
+ }
+
+ let selectedSessionID;
+ let retryIndex = 0;
+ let nextReportAt = 0;
+ let reportPending = false;
+ const syncSelectedSession = async () => {
+ const route = api.route.current;
+ const sessionID = route?.name === "session" ? route.params?.sessionID : undefined;
+ const session =
+ typeof sessionID === "string" && sessionID
+ ? api.state.session.get(sessionID)
+ : undefined;
+ if (!session || session.parentID) {
+ selectedSessionID = undefined;
+ retryIndex = 0;
+ nextReportAt = 0;
+ return;
+ }
+ if (sessionID !== selectedSessionID) {
+ selectedSessionID = sessionID;
+ retryIndex = 0;
+ nextReportAt = 0;
+ }
+ if (reportPending || Date.now() < nextReportAt) {
+ return;
+ }
+
+ const reportingSessionID = sessionID;
+ reportPending = true;
+ try {
+ await requestOnce(reportingSessionID);
+ } catch {
+ // Best-effort reporting retries below while the selected route remains active.
+ } finally {
+ reportPending = false;
+ }
+ if (selectedSessionID !== reportingSessionID) {
+ retryIndex = 0;
+ nextReportAt = 0;
+ return;
+ }
+ const retryDelay = SELECTION_RETRY_DELAYS_MS[retryIndex];
+ retryIndex += 1;
+ nextReportAt = retryDelay === undefined ? Number.POSITIVE_INFINITY : Date.now() + retryDelay;
+ };
+
+ await syncSelectedSession();
+ const routePoll = setInterval(() => void syncSelectedSession(), ROUTE_POLL_INTERVAL_MS);
+ api.lifecycle.onDispose(() => clearInterval(routePoll));
+ },
+};
diff --git a/src/integration/assets/opencode/herdr-tui-session.test.ts b/src/integration/assets/opencode/herdr-tui-session.test.ts
new file mode 100644
index 00000000..1bcb1f81
--- /dev/null
+++ b/src/integration/assets/opencode/herdr-tui-session.test.ts
@@ -0,0 +1,181 @@
+import { afterEach, beforeEach, expect, mock, test } from "bun:test";
+
+const requests: unknown[] = [];
+const activeDisposers: Array<() => void> = [];
+const requestWaiters: Array<() => void> = [];
+let importCounter = 0;
+
+mock.module("node:net", () => ({
+ default: {
+ createConnection(_path: string, onConnect: () => void) {
+ const handlers = new Map<string, () => void>();
+ const client = {
+ write(input: string) {
+ requests.push(JSON.parse(input.trim()));
+ requestWaiters.shift()?.();
+ queueMicrotask(() => client.emit("data"));
+ },
+ setTimeout() {},
+ on(event: string, handler: () => void) {
+ handlers.set(event, handler);
+ },
+ destroy() {},
+ emit(event: string) {
+ handlers.get(event)?.();
+ },
+ };
+ queueMicrotask(onConnect);
+ return client;
+ },
+ },
+}));
+
+beforeEach(() => {
+ requests.length = 0;
+ requestWaiters.length = 0;
+ process.env.HERDR_ENV = "1";
+ process.env.HERDR_SOCKET_PATH = "test.sock";
+ process.env.HERDR_PANE_ID = "test:p1";
+});
+
+afterEach(() => {
+ for (const dispose of activeDisposers.splice(0)) {
+ dispose();
+ }
+});
+
+async function loadPlugin() {
+ importCounter += 1;
+ const module = await import(`./herdr-tui-session.js?test=${importCounter}`);
+ return module.default;
+}
+
+function fakeApi() {
+ const sessions = new Map<string, { id: string; parentID?: string }>();
+ let current: { name: string; params?: { sessionID: string } } = { name: "home" };
+ let dispose: (() => void) | undefined;
+ activeDisposers.push(() => dispose?.());
+
+ return {
+ api: {
+ route: {
+ get current() {
+ return current;
+ },
+ },
+ state: {
+ session: {
+ get(sessionID: string) {
+ return sessions.get(sessionID);
+ },
+ },
+ },
+ lifecycle: {
+ onDispose(handler: () => void) {
+ dispose = handler;
+ return () => {};
+ },
+ },
+ },
+ addSession(session: { id: string; parentID?: string }) {
+ sessions.set(session.id, session);
+ },
+ select(sessionID: string) {
+ current = { name: "session", params: { sessionID } };
+ },
+ dispose() {
+ dispose?.();
+ },
+ };
+}
+
+function waitForNextRequest(): Promise<void> {
+ return new Promise((resolve) => requestWaiters.push(resolve));
+}
+
+test("reports a root session when only the local route changes", async () => {
+ const plugin = await loadPlugin();
+ const tui = fakeApi();
+ tui.addSession({ id: "session-a" });
+ await plugin.tui(tui.api);
+
+ const dispatched = waitForNextRequest();
+ tui.select("session-a");
+ await dispatched;
+
+ expect(requests).toHaveLength(1);
+ expect(requestParam(requests[0], "agent_session_id")).toBe("session-a");
+ expect(requestParam(requests[0], "session_start_source")).toBe("select");
+ expect(requestParam(requests[0], "seq")).toBeUndefined();
+});
+
+test("retries an initial selection while Herdr detects the process", async () => {
+ const plugin = await loadPlugin();
+ const tui = fakeApi();
+ tui.addSession({ id: "session-a" });
+ tui.select("session-a");
+
+ await plugin.tui(tui.api);
+ await new Promise((resolve) => setTimeout(resolve, 125));
+
+ expect(requests.map((request) => requestParam(request, "agent_session_id"))).toEqual([
+ "session-a",
+ "session-a",
+ ]);
+});
+
+test("does not report root sessions not selected by this TUI", async () => {
+ const plugin = await loadPlugin();
+ const tui = fakeApi();
+ tui.addSession({ id: "session-a" });
+ tui.addSession({ id: "session-b" });
+ tui.select("session-a");
+ await plugin.tui(tui.api);
+
+ await new Promise((resolve) => setTimeout(resolve, 125));
+
+ expect(requests.length).toBeGreaterThan(0);
+ expect(requests.every((request) => requestParam(request, "agent_session_id") === "session-a")).toBe(
+ true,
+ );
+});
+
+test("does not replace the root session with a selected child session", async () => {
+ const plugin = await loadPlugin();
+ const tui = fakeApi();
+ tui.addSession({ id: "root-session" });
+ tui.addSession({ id: "child-session", parentID: "root-session" });
+ tui.select("root-session");
+ await plugin.tui(tui.api);
+ expect(requests).toHaveLength(1);
+
+ tui.select("child-session");
+ await new Promise((resolve) => setTimeout(resolve, 125));
+
+ expect(requests).toHaveLength(1);
+ expect(requestParam(requests[0], "agent_session_id")).toBe("root-session");
+});
+
+test("stops route polling when the TUI plugin is disposed", async () => {
+ const plugin = await loadPlugin();
+ const tui = fakeApi();
+ tui.addSession({ id: "session-a" });
+ await plugin.tui(tui.api);
+ tui.dispose();
+ tui.select("session-a");
+
+ await new Promise((resolve) => setTimeout(resolve, 125));
+
+ expect(requests).toHaveLength(0);
+});
+
+function requestParam(request: unknown, name: string): unknown {
+ if (!isRecord(request) || !isRecord(request.params)) {
+ return undefined;
+ }
+ return request.params[name];
+}
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+ return typeof value === "object" && value !== null;
+}
diff --git a/src/integration/mod.rs b/src/integration/mod.rs
index 89d4d34d..9630b578 100644
--- a/src/integration/mod.rs
+++ b/src/integration/mod.rs
@@ -4,6 +4,7 @@ mod command;
mod config_edit;
mod env;
mod file_ops;
+mod opencode_config;
mod registry;
mod targets;
mod types;
@@ -166,7 +167,10 @@ 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 = 9;
+const OPENCODE_TUI_PLUGIN_INSTALL_NAME: &str = "herdr-tui-session.js";
+const OPENCODE_TUI_PLUGIN_SPEC: &str = "./herdr-tui-session.js";
+const OPENCODE_TUI_PLUGIN_ASSET: &str = include_str!("assets/opencode/herdr-tui-session.js");
+const OPENCODE_INTEGRATION_VERSION: u32 = 10;
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 = 4;
diff --git a/src/integration/opencode_config.rs b/src/integration/opencode_config.rs
new file mode 100644
index 00000000..57431f93
--- /dev/null
+++ b/src/integration/opencode_config.rs
@@ -0,0 +1,298 @@
+use std::fs;
+use std::io;
+use std::path::{Path, PathBuf};
+
+use jsonc_parser::cst::{CstInputValue, CstRootNode};
+use jsonc_parser::ParseOptions;
+use serde_json::Value;
+
+const TUI_CONFIG_NAME: &str = "tui.jsonc";
+
+pub(crate) fn tui_config_path(config_dir: &Path) -> PathBuf {
+ config_dir.join(TUI_CONFIG_NAME)
+}
+
+pub(crate) fn validate_tui_plugin_config(config_dir: &Path) -> io::Result<()> {
+ let config_path = tui_config_path(config_dir);
+ if !config_path.is_file() {
+ return Ok(());
+ }
+
+ let content = fs::read_to_string(&config_path)?;
+ let root = parse_root(&content, &config_path)?;
+ let object = root_object(&root, &config_path)?;
+ if object
+ .get("plugin")
+ .is_some_and(|property| property.array_value().is_none())
+ {
+ return Err(invalid_plugin_list(&config_path));
+ }
+ Ok(())
+}
+
+pub(crate) fn add_tui_plugin(config_dir: &Path, plugin_spec: &str) -> io::Result<PathBuf> {
+ let config_path = tui_config_path(config_dir);
+ let content = if config_path.is_file() {
+ fs::read_to_string(&config_path)?
+ } else {
+ "{}\n".to_string()
+ };
+ let root = parse_root(&content, &config_path)?;
+ let object = root_object(&root, &config_path)?;
+
+ match object.get("plugin") {
+ Some(property) => {
+ let plugins = property
+ .array_value()
+ .ok_or_else(|| invalid_plugin_list(&config_path))?;
+ if plugins.elements().iter().any(|entry| {
+ entry
+ .to_serde_value()
+ .is_some_and(|entry| plugin_entry_matches(&entry, plugin_spec))
+ }) {
+ return Ok(config_path);
+ }
+ plugins.append(CstInputValue::String(plugin_spec.to_string()));
+ }
+ None => {
+ object.append(
+ "plugin",
+ CstInputValue::Array(vec![CstInputValue::String(plugin_spec.to_string())]),
+ );
+ }
+ }
+
+ fs::write(&config_path, root.to_string())?;
+ Ok(config_path)
+}
+
+pub(crate) fn remove_tui_plugin(config_dir: &Path, plugin_spec: &str) -> io::Result<bool> {
+ let config_path = tui_config_path(config_dir);
+ if !config_path.is_file() {
+ return Ok(false);
+ }
+
+ let content = fs::read_to_string(&config_path)?;
+ let root = parse_root(&content, &config_path)?;
+ let object = root_object(&root, &config_path)?;
+ let Some(property) = object.get("plugin") else {
+ return Ok(false);
+ };
+ let plugins = property
+ .array_value()
+ .ok_or_else(|| invalid_plugin_list(&config_path))?;
+ let mut removed = false;
+ for entry in plugins.elements() {
+ if entry
+ .to_serde_value()
+ .is_some_and(|entry| plugin_entry_matches(&entry, plugin_spec))
+ {
+ entry.remove();
+ removed = true;
+ }
+ }
+ if !removed {
+ return Ok(false);
+ }
+ if plugins.elements().is_empty() {
+ property.remove();
+ }
+
+ fs::write(&config_path, root.to_string())?;
+ Ok(true)
+}
+
+pub(crate) fn tui_plugin_is_configured(config_dir: &Path, plugin_spec: &str) -> bool {
+ let config_path = tui_config_path(config_dir);
+ let Ok(content) = fs::read_to_string(&config_path) else {
+ return false;
+ };
+ let Ok(root) = parse_root(&content, &config_path) else {
+ return false;
+ };
+ let Ok(object) = root_object(&root, &config_path) else {
+ return false;
+ };
+ object
+ .get("plugin")
+ .and_then(|property| property.array_value())
+ .is_some_and(|plugins| {
+ plugins.elements().iter().any(|entry| {
+ entry
+ .to_serde_value()
+ .is_some_and(|entry| plugin_entry_matches(&entry, plugin_spec))
+ })
+ })
+}
+
+fn parse_root(content: &str, path: &Path) -> io::Result<CstRootNode> {
+ CstRootNode::parse(content, &jsonc_parse_options()).map_err(|err| {
+ io::Error::other(format!(
+ "failed to parse OpenCode TUI config at {}: {err}",
+ path.display()
+ ))
+ })
+}
+
+fn root_object(root: &CstRootNode, path: &Path) -> io::Result<jsonc_parser::cst::CstObject> {
+ root.value()
+ .and_then(|value| value.as_object())
+ .ok_or_else(|| invalid_root(path))
+}
+
+fn jsonc_parse_options() -> ParseOptions {
+ ParseOptions {
+ allow_comments: true,
+ allow_loose_object_property_names: false,
+ allow_trailing_commas: true,
+ allow_missing_commas: false,
+ allow_single_quoted_strings: false,
+ allow_hexadecimal_numbers: false,
+ allow_unary_plus_numbers: false,
+ }
+}
+
+fn plugin_entry_matches(entry: &Value, plugin_spec: &str) -> bool {
+ entry.as_str() == Some(plugin_spec)
+ || entry
+ .as_array()
+ .and_then(|parts| parts.first())
+ .and_then(Value::as_str)
+ == Some(plugin_spec)
+}
+
+fn invalid_root(path: &Path) -> io::Error {
+ io::Error::other(format!(
+ "OpenCode TUI config at {} must be a JSON object",
+ path.display()
+ ))
+}
+
+fn invalid_plugin_list(path: &Path) -> io::Error {
+ io::Error::other(format!(
+ "OpenCode TUI config plugin list at {} must be an array",
+ path.display()
+ ))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use serde_json::json;
+
+ fn unique_dir() -> PathBuf {
+ let dir = std::env::temp_dir().join(format!(
+ "herdr-opencode-config-{}-{}",
+ std::process::id(),
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .expect("system clock should be after epoch")
+ .as_nanos()
+ ));
+ fs::create_dir_all(&dir).expect("temporary config directory should be created");
+ dir
+ }
+
+ fn parse_config(path: &Path) -> Value {
+ let content = fs::read_to_string(path).unwrap();
+ parse_root(&content, path)
+ .unwrap()
+ .value()
+ .and_then(|value| value.to_serde_value())
+ .unwrap()
+ }
+
+ #[test]
+ fn add_and_remove_tui_plugin_preserves_jsonc_config() {
+ let dir = unique_dir();
+ let config_path = dir.join(TUI_CONFIG_NAME);
+ fs::write(
+ &config_path,
+ concat!(
+ "{\n",
+ " // Keep this comment.\n",
+ " \"theme\": \"system\",\n",
+ " \"plugin\": [\"example\", [\"configured\", {\"enabled\": true}]],\n",
+ "}\n",
+ ),
+ )
+ .unwrap();
+
+ add_tui_plugin(&dir, "./herdr-tui-state.js").unwrap();
+ add_tui_plugin(&dir, "./herdr-tui-state.js").unwrap();
+ let installed_content = fs::read_to_string(&config_path).unwrap();
+ assert!(installed_content.contains("// Keep this comment."));
+ let installed = parse_config(&config_path);
+ assert_eq!(installed["theme"], "system");
+ assert_eq!(
+ installed["plugin"],
+ json!([
+ "example",
+ ["configured", {"enabled": true}],
+ "./herdr-tui-state.js"
+ ])
+ );
+
+ assert!(remove_tui_plugin(&dir, "./herdr-tui-state.js").unwrap());
+ let removed_content = fs::read_to_string(&config_path).unwrap();
+ assert!(removed_content.contains("// Keep this comment."));
+ let removed = parse_config(&config_path);
+ assert_eq!(removed["theme"], "system");
+ assert_eq!(
+ removed["plugin"],
+ json!(["example", ["configured", {"enabled": true}]])
+ );
+
+ fs::remove_dir_all(dir).unwrap();
+ }
+
+ #[test]
+ fn managed_jsonc_leaves_opencode_migration_target_absent() {
+ let dir = unique_dir();
+ let legacy_config_path = dir.join("opencode.json");
+ let legacy_config = "{\n \"theme\": \"system\"\n}\n";
+ fs::write(&legacy_config_path, legacy_config).unwrap();
+
+ let config_path = add_tui_plugin(&dir, "./herdr-tui-state.js").unwrap();
+
+ assert_eq!(config_path, dir.join("tui.jsonc"));
+ assert!(!dir.join("tui.json").exists());
+ assert_eq!(
+ fs::read_to_string(legacy_config_path).unwrap(),
+ legacy_config
+ );
+ assert_eq!(
+ parse_config(&config_path),
+ json!({ "plugin": ["./herdr-tui-state.js"] })
+ );
+
+ fs::remove_dir_all(dir).unwrap();
+ }
+
+ #[test]
+ fn remove_tui_plugin_leaves_empty_managed_config() {
+ let dir = unique_dir();
+ let config_path = add_tui_plugin(&dir, "./herdr-tui-state.js").unwrap();
+
+ assert!(remove_tui_plugin(&dir, "./herdr-tui-state.js").unwrap());
+ assert!(config_path.is_file());
+ assert_eq!(parse_config(&config_path), json!({}));
+
+ fs::remove_dir_all(dir).unwrap();
+ }
+
+ #[test]
+ fn configured_tui_plugin_accepts_option_tuple() {
+ let dir = unique_dir();
+ fs::write(
+ dir.join(TUI_CONFIG_NAME),
+ r#"{"plugin":[["./herdr-tui-state.js",{"enabled":true}]]}"#,
+ )
+ .unwrap();
+
+ assert!(tui_plugin_is_configured(&dir, "./herdr-tui-state.js"));
+ assert!(remove_tui_plugin(&dir, "./herdr-tui-state.js").unwrap());
+
+ fs::remove_dir_all(dir).unwrap();
+ }
+}
diff --git a/src/integration/registry.rs b/src/integration/registry.rs
index 569494a2..b7bb3bf2 100644
--- a/src/integration/registry.rs
+++ b/src/integration/registry.rs
@@ -405,6 +405,22 @@ fn grok_hook_config_is_valid(hook_path: &Path) -> bool {
.is_some_and(|config| config == super::targets::grok_hook_config(hook_path))
}
+fn opencode_tui_integration_is_valid(plugin_path: &Path, expected_version: u32) -> bool {
+ let Some(config_dir) = plugin_path.parent().and_then(Path::parent) else {
+ return false;
+ };
+ let tui_plugin_path = config_dir.join(super::OPENCODE_TUI_PLUGIN_INSTALL_NAME);
+ let tui_plugin_current = fs::read_to_string(tui_plugin_path)
+ .ok()
+ .and_then(|content| parse_integration_version(&content))
+ .is_some_and(|version| version >= expected_version);
+ tui_plugin_current
+ && super::opencode_config::tui_plugin_is_configured(
+ config_dir,
+ super::OPENCODE_TUI_PLUGIN_SPEC,
+ )
+}
+
pub(crate) fn integration_status_at(
target: crate::api::schema::IntegrationTarget,
path: PathBuf,
@@ -439,6 +455,12 @@ pub(crate) fn integration_status_at(
{
state = super::IntegrationStatusKind::Outdated;
}
+ if target == crate::api::schema::IntegrationTarget::Opencode
+ && state == super::IntegrationStatusKind::Current
+ && !opencode_tui_integration_is_valid(&path, expected_version)
+ {
+ state = super::IntegrationStatusKind::Outdated;
+ }
super::IntegrationStatus {
target,
diff --git a/src/integration/targets.rs b/src/integration/targets.rs
index 0cf2f933..375d2713 100644
--- a/src/integration/targets.rs
+++ b/src/integration/targets.rs
@@ -25,6 +25,9 @@ use super::env::{
use super::file_ops::{
make_executable, remove_dir_all_if_exists, remove_file_if_exists, remove_legacy_bash_hook_file,
};
+use super::opencode_config::{
+ add_tui_plugin, remove_tui_plugin, tui_config_path, validate_tui_plugin_config,
+};
use super::types::{
AntigravityCliInstallPaths, AntigravityCliUninstallResult, ClaudeInstallPaths,
ClaudeUninstallResult, CodexInstallPaths, CodexUninstallResult, CopilotInstallPaths,
@@ -49,9 +52,9 @@ use super::{
KIMI_HOOK_ASSET, KIMI_HOOK_INSTALL_NAME, MASTRACODE_HOOK_ASSET, MASTRACODE_HOOK_EVENTS,
MASTRACODE_HOOK_INSTALL_NAME, MASTRACODE_HOOK_TIMEOUT_MS, MASTRACODE_REMOVED_HOOK_EVENTS,
OMP_EXTENSION_ASSET, OMP_EXTENSION_INSTALL_NAME, OPENCODE_PLUGIN_ASSET,
- OPENCODE_PLUGIN_INSTALL_NAME, PI_EXTENSION_ASSET, PI_EXTENSION_INSTALL_NAME,
- QODERCLI_HOOK_ASSET, QODERCLI_HOOK_EVENTS, QODERCLI_HOOK_INSTALL_NAME,
- QODERCLI_REMOVED_LIFECYCLE_HOOK_EVENTS,
+ OPENCODE_PLUGIN_INSTALL_NAME, OPENCODE_TUI_PLUGIN_ASSET, OPENCODE_TUI_PLUGIN_INSTALL_NAME,
+ OPENCODE_TUI_PLUGIN_SPEC, PI_EXTENSION_ASSET, PI_EXTENSION_INSTALL_NAME, QODERCLI_HOOK_ASSET,
+ QODERCLI_HOOK_EVENTS, QODERCLI_HOOK_INSTALL_NAME, QODERCLI_REMOVED_LIFECYCLE_HOOK_EVENTS,
};
fn ensure_extension_dir(dir: &Path, agent: &str) -> io::Result<()> {
@@ -452,13 +455,21 @@ pub(crate) fn install_opencode() -> io::Result<OpenCodeInstallPaths> {
)));
}
+ validate_tui_plugin_config(&dir)?;
let plugins_dir = dir.join("plugins");
fs::create_dir_all(&plugins_dir)?;
let plugin_path = plugins_dir.join(OPENCODE_PLUGIN_INSTALL_NAME);
fs::write(&plugin_path, OPENCODE_PLUGIN_ASSET)?;
+ let tui_plugin_path = dir.join(OPENCODE_TUI_PLUGIN_INSTALL_NAME);
+ fs::write(&tui_plugin_path, OPENCODE_TUI_PLUGIN_ASSET)?;
+ let tui_config_path = add_tui_plugin(&dir, OPENCODE_TUI_PLUGIN_SPEC)?;
- Ok(OpenCodeInstallPaths { plugin_path })
+ Ok(OpenCodeInstallPaths {
+ plugin_path,
+ tui_plugin_path,
+ tui_config_path,
+ })
}
pub(crate) fn install_kilo() -> io::Result<KiloInstallPaths> {
@@ -801,14 +812,38 @@ pub(crate) fn uninstall_droid() -> io::Result<DroidUninstallResult> {
}
pub(crate) fn uninstall_opencode() -> io::Result<OpenCodeUninstallResult> {
- let plugin_path = opencode_dir()?
- .join("plugins")
- .join(OPENCODE_PLUGIN_INSTALL_NAME);
- let removed_plugin = remove_file_if_exists(&plugin_path)?;
+ let dir = opencode_dir()?;
+ let tui_config_path = tui_config_path(&dir);
+ let plugin_path = dir.join("plugins").join(OPENCODE_PLUGIN_INSTALL_NAME);
+ let tui_plugin_path = dir.join(OPENCODE_TUI_PLUGIN_INSTALL_NAME);
+ let mut errors = Vec::new();
+ let updated_tui_config =
+ remove_tui_plugin(&dir, OPENCODE_TUI_PLUGIN_SPEC).unwrap_or_else(|err| {
+ errors.push(err.to_string());
+ false
+ });
+ let removed_plugin = remove_file_if_exists(&plugin_path).unwrap_or_else(|err| {
+ errors.push(format!("failed to remove {}: {err}", plugin_path.display()));
+ false
+ });
+ let removed_tui_plugin = remove_file_if_exists(&tui_plugin_path).unwrap_or_else(|err| {
+ errors.push(format!(
+ "failed to remove {}: {err}",
+ tui_plugin_path.display()
+ ));
+ false
+ });
+ if !errors.is_empty() {
+ return Err(io::Error::other(errors.join("; ")));
+ }
Ok(OpenCodeUninstallResult {
plugin_path,
+ tui_plugin_path,
+ tui_config_path,
removed_plugin,
+ removed_tui_plugin,
+ updated_tui_config,
})
}
diff --git a/src/integration/tests.rs b/src/integration/tests.rs
index 0081fcdb..6eaf7a39 100644
--- a/src/integration/tests.rs
+++ b/src/integration/tests.rs
@@ -2201,7 +2201,7 @@ fn install_droid_errors_when_config_dir_missing() {
}
#[test]
-fn install_opencode_writes_plugin_to_plugins_dir() {
+fn install_opencode_writes_server_and_tui_plugins() {
let _lock = integration_env_lock();
let base = unique_base();
let home = base.join("home");
@@ -2210,7 +2210,6 @@ fn install_opencode_writes_plugin_to_plugins_dir() {
std::env::set_var("HOME", &home);
let installed = install_opencode().unwrap();
- let plugin_content = fs::read_to_string(&installed.plugin_path).unwrap();
assert_eq!(
installed.plugin_path,
@@ -2218,30 +2217,126 @@ fn install_opencode_writes_plugin_to_plugins_dir() {
.join("plugins")
.join(OPENCODE_PLUGIN_INSTALL_NAME)
);
- assert_eq!(plugin_content, OPENCODE_PLUGIN_ASSET);
+ assert_eq!(
+ fs::read_to_string(&installed.plugin_path).unwrap(),
+ OPENCODE_PLUGIN_ASSET
+ );
+ assert_eq!(
+ installed.tui_plugin_path,
+ opencode_dir.join(OPENCODE_TUI_PLUGIN_INSTALL_NAME)
+ );
+ assert_eq!(
+ fs::read_to_string(&installed.tui_plugin_path).unwrap(),
+ OPENCODE_TUI_PLUGIN_ASSET
+ );
+ assert_eq!(installed.tui_config_path, opencode_dir.join("tui.jsonc"));
+ let tui_config: Value =
+ serde_json::from_str(&fs::read_to_string(&installed.tui_config_path).unwrap()).unwrap();
+ assert_eq!(tui_config["plugin"], json!([OPENCODE_TUI_PLUGIN_SPEC]));
std::env::remove_var("HOME");
let _ = fs::remove_dir_all(base);
}
#[test]
-fn uninstall_opencode_removes_plugin_when_present() {
+fn opencode_status_requires_the_tui_plugin_and_config_entry() {
let _lock = integration_env_lock();
let base = unique_base();
let home = base.join("home");
- let opencode_dir = home.join(".config/opencode/plugins");
+ let opencode_dir = home.join(".config/opencode");
fs::create_dir_all(&opencode_dir).unwrap();
- fs::write(
- opencode_dir.join(OPENCODE_PLUGIN_INSTALL_NAME),
- OPENCODE_PLUGIN_ASSET,
- )
- .unwrap();
std::env::set_var("HOME", &home);
+ let installed = install_opencode().unwrap();
+ let status = || {
+ integration_status_at(
+ crate::api::schema::IntegrationTarget::Opencode,
+ installed.plugin_path.clone(),
+ OPENCODE_INTEGRATION_VERSION,
+ )
+ .state
+ };
+
+ assert_eq!(status(), IntegrationStatusKind::Current);
+ fs::remove_file(&installed.tui_plugin_path).unwrap();
+ assert_eq!(status(), IntegrationStatusKind::Outdated);
+ fs::write(&installed.tui_plugin_path, OPENCODE_TUI_PLUGIN_ASSET).unwrap();
+ super::opencode_config::remove_tui_plugin(&opencode_dir, OPENCODE_TUI_PLUGIN_SPEC).unwrap();
+ assert_eq!(status(), IntegrationStatusKind::Outdated);
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn uninstall_opencode_removes_plugins_and_managed_tui_config_entry() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let opencode_dir = home.join(".config/opencode");
+ fs::create_dir_all(&opencode_dir).unwrap();
+ std::env::set_var("HOME", &home);
+ let installed = install_opencode().unwrap();
let result = uninstall_opencode().unwrap();
assert!(result.removed_plugin);
+ assert!(result.removed_tui_plugin);
+ assert!(result.updated_tui_config);
assert!(!result.plugin_path.exists());
+ assert!(!result.tui_plugin_path.exists());
+ assert!(result.tui_config_path.exists());
+ let tui_config: Value =
+ serde_json::from_str(&fs::read_to_string(&result.tui_config_path).unwrap()).unwrap();
+ assert_eq!(tui_config, json!({}));
+ assert_eq!(installed.plugin_path, result.plugin_path);
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn install_opencode_invalid_tui_config_does_not_write_plugins() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let opencode_dir = home.join(".config/opencode");
+ fs::create_dir_all(&opencode_dir).unwrap();
+ fs::write(opencode_dir.join("tui.jsonc"), r#"{"plugin":{}}"#).unwrap();
+ std::env::set_var("HOME", &home);
+
+ let err = install_opencode().unwrap_err().to_string();
+
+ assert!(err.contains("plugin list"));
+ assert!(!opencode_dir
+ .join("plugins")
+ .join(OPENCODE_PLUGIN_INSTALL_NAME)
+ .exists());
+ assert!(!opencode_dir.join(OPENCODE_TUI_PLUGIN_INSTALL_NAME).exists());
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn uninstall_opencode_removes_plugins_when_tui_config_is_invalid() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let opencode_dir = home.join(".config/opencode");
+ let plugins_dir = opencode_dir.join("plugins");
+ fs::create_dir_all(&plugins_dir).unwrap();
+ let plugin_path = plugins_dir.join(OPENCODE_PLUGIN_INSTALL_NAME);
+ let tui_plugin_path = opencode_dir.join(OPENCODE_TUI_PLUGIN_INSTALL_NAME);
+ fs::write(&plugin_path, OPENCODE_PLUGIN_ASSET).unwrap();
+ fs::write(&tui_plugin_path, OPENCODE_TUI_PLUGIN_ASSET).unwrap();
+ fs::write(opencode_dir.join("tui.jsonc"), "{\"plugin\":").unwrap();
+ std::env::set_var("HOME", &home);
+
+ let err = uninstall_opencode().unwrap_err().to_string();
+
+ assert!(err.contains("failed to parse OpenCode TUI config"));
+ assert!(!plugin_path.exists());
+ assert!(!tui_plugin_path.exists());
std::env::remove_var("HOME");
let _ = fs::remove_dir_all(base);
diff --git a/src/integration/types.rs b/src/integration/types.rs
index 09a235b0..c5c24267 100644
--- a/src/integration/types.rs
+++ b/src/integration/types.rs
@@ -42,6 +42,8 @@ pub(crate) struct DroidInstallPaths {
#[derive(Debug)]
pub(crate) struct OpenCodeInstallPaths {
pub plugin_path: PathBuf,
+ pub tui_plugin_path: PathBuf,
+ pub tui_config_path: PathBuf,
}
#[derive(Debug)]
@@ -225,7 +227,11 @@ pub(crate) struct DroidUninstallResult {
#[derive(Debug)]
pub(crate) struct OpenCodeUninstallResult {
pub plugin_path: PathBuf,
+ pub tui_plugin_path: PathBuf,
+ pub tui_config_path: PathBuf,
pub removed_plugin: bool,
+ pub removed_tui_plugin: bool,
+ pub updated_tui_config: bool,
}
#[derive(Debug)]
diff --git a/src/terminal/state.rs b/src/terminal/state.rs
index 5ed5adc4..56064a94 100644
--- a/src/terminal/state.rs
+++ b/src/terminal/state.rs
@@ -844,7 +844,7 @@ impl TerminalState {
let process_present = known_agent.is_some()
&& self.detected_agent == known_agent
&& self.recent_agent_process_exit.is_none();
- let session_anchored = self
+ let anchored_session_ref = self
.hook_authority
.as_ref()
.filter(|authority| authority.source == source && authority.agent_label == agent_label)
@@ -854,12 +854,20 @@ impl TerminalState {
.as_ref()
.filter(|session| session.source == source && session.agent == agent_label)
.map(|session| &session.session_ref)
- })
- .is_some_and(|anchored| {
- session_ref
- .as_ref()
- .is_none_or(|incoming| incoming == anchored)
});
+ let session_anchored = anchored_session_ref.is_some_and(|anchored| {
+ session_ref
+ .as_ref()
+ .is_none_or(|incoming| incoming == anchored)
+ });
+ let opencode_cross_talk = (source, agent_label) == ("herdr:opencode", "opencode")
+ && process_present
+ && anchored_session_ref
+ .zip(session_ref.as_ref())
+ .is_some_and(|(anchored, incoming)| anchored != incoming);
+ if opencode_cross_talk {
+ return FullLifecycleHookReportRoute::Ignore;
+ }
if let Some(suppressed) = self.suppressed_full_lifecycle_hook_reports.get(source) {
if suppressed.agent_label != agent_label {
return FullLifecycleHookReportRoute::Ignore;
@@ -1284,7 +1292,7 @@ impl TerminalState {
Some("startup" | "clear" | "resume" | "compact")
) | ("herdr:mastracode", "mastracode", Some("startup"))
| ("herdr:hermes", "hermes", Some("startup" | "new" | "resume"))
- | ("herdr:opencode", "opencode", Some("new"))
+ | ("herdr:opencode", "opencode", Some("select"))
| ("herdr:pi", "pi", Some("new" | "resume" | "fork"))
| (
"herdr:omp",
@@ -1298,10 +1306,20 @@ impl TerminalState {
fn session_start_source_is_recognized(session_start_source: Option<&str>) -> bool {
matches!(
session_start_source,
- Some("startup" | "clear" | "resume" | "compact" | "new" | "fork")
+ Some("startup" | "clear" | "resume" | "compact" | "new" | "fork" | "select")
)
}
+ fn is_unsequenced_opencode_selection(
+ source: &str,
+ agent_label: &str,
+ session_start_source: Option<&str>,
+ seq: Option<u64>,
+ ) -> bool {
+ (source, agent_label, session_start_source, seq)
+ == ("herdr:opencode", "opencode", Some("select"), None)
+ }
+
pub fn set_persisted_agent_session(
&mut self,
session: crate::agent_resume::PersistedAgentSession,
@@ -1346,7 +1364,48 @@ impl TerminalState {
&& authority.agent_label == agent_label
&& authority.session_ref.is_some()
}) || self.persisted_agent_session_matches(&source, &agent_label);
- if full_lifecycle_source && (!process_present || generation_gated || !session_anchored) {
+ let unsequenced_selection = Self::is_unsequenced_opencode_selection(
+ &source,
+ &agent_label,
+ session_start_source.as_deref(),
+ seq,
+ );
+ let selection_can_reconcile = unsequenced_selection && process_present;
+ if selection_can_reconcile {
+ self.suppressed_full_lifecycle_hook_reports.remove(&source);
+ } else if full_lifecycle_source && unsequenced_selection {
+ let previous_session_ref = self
+ .hook_authority
+ .as_ref()
+ .filter(|authority| {
+ authority.source == source && authority.agent_label == agent_label
+ })
+ .and_then(|authority| authority.session_ref.clone())
+ .or_else(|| {
+ self.persisted_agent_session
+ .as_ref()
+ .filter(|session| session.source == source && session.agent == agent_label)
+ .map(|session| session.session_ref.clone())
+ });
+ let suppressed = self
+ .suppressed_full_lifecycle_hook_reports
+ .entry(source)
+ .or_insert_with(|| SuppressedFullLifecycleHookReport {
+ agent_label,
+ session_ref: previous_session_ref,
+ observed_at: Instant::now(),
+ reason: FullLifecycleHookSuppressionReason::ProcessExit,
+ replacement_session_ref: None,
+ pending_replacement_report: None,
+ });
+ suppressed.replacement_session_ref = Some(session_ref);
+ suppressed.pending_replacement_report = None;
+ return None;
+ }
+ if full_lifecycle_source
+ && !selection_can_reconcile
+ && (!process_present || generation_gated || !session_anchored)
+ {
if !Self::session_start_source_is_recognized(session_start_source.as_deref()) {
return None;
}
@@ -1408,7 +1467,7 @@ impl TerminalState {
}
return None;
}
- if !self.accept_hook_report(&source, seq) {
+ if !unsequenced_selection && !self.accept_hook_report(&source, seq) {
return None;
}
if self.known_agent_label_conflicts_with_detected_agent(&agent_label) {
@@ -4426,37 +4485,272 @@ mod tests {
}
#[test]
- fn opencode_new_session_ref_replaces_existing_session_ref() {
+ fn opencode_server_new_does_not_replace_existing_session_ref() {
let mut terminal = test_terminal();
terminal.set_detected_state(Some(Agent::OpenCode), AgentState::Idle);
terminal
.set_agent_session_ref_for_session_start(
"herdr:opencode".into(),
"opencode".into(),
- crate::agent_resume::AgentSessionRef::id("opencode-old"),
+ crate::agent_resume::AgentSessionRef::id("opencode-visible"),
+ None,
+ Some("select".into()),
+ )
+ .expect("local selection should be accepted");
+
+ let mutation = terminal.set_agent_session_ref_for_session_start(
+ "herdr:opencode".into(),
+ "opencode".into(),
+ crate::agent_resume::AgentSessionRef::id("opencode-attached-client"),
+ Some(21),
+ Some("new".into()),
+ );
+
+ assert!(mutation.is_none());
+ assert_eq!(
+ terminal
+ .persisted_agent_session
+ .as_ref()
+ .map(|session| session.session_ref.value.as_str()),
+ Some("opencode-visible")
+ );
+ }
+
+ #[test]
+ fn opencode_server_resume_does_not_replace_existing_session_ref() {
+ let mut terminal = test_terminal();
+ terminal.set_detected_state(Some(Agent::OpenCode), AgentState::Idle);
+ terminal
+ .set_agent_session_ref_for_session_start(
+ "herdr:opencode".into(),
+ "opencode".into(),
+ crate::agent_resume::AgentSessionRef::id("opencode-visible"),
+ None,
+ Some("select".into()),
+ )
+ .expect("local selection should be accepted");
+
+ let mutation = terminal.set_agent_session_ref_for_session_start(
+ "herdr:opencode".into(),
+ "opencode".into(),
+ crate::agent_resume::AgentSessionRef::id("opencode-attached-client"),
+ Some(21),
+ Some("resume".into()),
+ );
+
+ assert!(mutation.is_none());
+ assert_eq!(
+ terminal
+ .persisted_agent_session
+ .as_ref()
+ .map(|session| session.session_ref.value.as_str()),
+ Some("opencode-visible")
+ );
+ }
+
+ #[test]
+ fn opencode_tui_selection_anchors_after_process_detection() {
+ let mut terminal = test_terminal();
+ let startup_selection = terminal.set_agent_session_ref_for_session_start(
+ "herdr:opencode".into(),
+ "opencode".into(),
+ crate::agent_resume::AgentSessionRef::id("opencode-startup-selection"),
+ None,
+ Some("select".into()),
+ );
+ assert!(startup_selection.is_none());
+ assert_eq!(
+ terminal
+ .suppressed_full_lifecycle_hook_reports
+ .get("herdr:opencode")
+ .and_then(|suppressed| suppressed.replacement_session_ref.as_ref())
+ .map(|session| session.value.as_str()),
+ Some("opencode-startup-selection")
+ );
+
+ terminal.set_detected_state(Some(Agent::OpenCode), AgentState::Idle);
+ assert_eq!(
+ terminal
+ .persisted_agent_session
+ .as_ref()
+ .map(|session| session.session_ref.value.as_str()),
+ Some("opencode-startup-selection")
+ );
+ assert!(!terminal
+ .suppressed_full_lifecycle_hook_reports
+ .contains_key("herdr:opencode"));
+
+ terminal.suppressed_full_lifecycle_hook_reports.insert(
+ "herdr:opencode".into(),
+ SuppressedFullLifecycleHookReport {
+ agent_label: "opencode".into(),
+ session_ref: None,
+ observed_at: Instant::now(),
+ reason: FullLifecycleHookSuppressionReason::ProcessExit,
+ replacement_session_ref: None,
+ pending_replacement_report: None,
+ },
+ );
+ let selected = terminal
+ .set_agent_session_ref_for_session_start(
+ "herdr:opencode".into(),
+ "opencode".into(),
+ crate::agent_resume::AgentSessionRef::id("opencode-reselected"),
+ None,
+ Some("select".into()),
+ )
+ .expect("local TUI selection should reconcile generation suppression");
+
+ assert!(selected.session_ref_changed);
+ assert!(!terminal
+ .suppressed_full_lifecycle_hook_reports
+ .contains_key("herdr:opencode"));
+ assert_eq!(
+ terminal
+ .persisted_agent_session
+ .as_ref()
+ .map(|session| session.session_ref.value.as_str()),
+ Some("opencode-reselected")
+ );
+ assert!(!terminal
+ .hook_report_sequences
+ .contains_key("herdr:opencode"));
+ }
+
+ #[test]
+ fn opencode_tui_selection_reanchors_full_lifecycle_authority() {
+ let mut terminal = test_terminal();
+ let old_session = crate::agent_resume::AgentSessionRef::id("opencode-newer").unwrap();
+ let selected_session =
+ crate::agent_resume::AgentSessionRef::id("opencode-selected-older").unwrap();
+ anchor_full_lifecycle_session(
+ &mut terminal,
+ Agent::OpenCode,
+ "herdr:opencode",
+ "opencode",
+ old_session.clone(),
+ );
+ terminal
+ .set_hook_authority_with_session_ref(
+ "herdr:opencode".into(),
+ "opencode".into(),
+ AgentState::Idle,
+ None,
+ Some(old_session.clone()),
Some(20),
- Some("new".into()),
)
- .expect("initial session should be accepted");
+ .expect("initial session should own lifecycle state");
+ let attached_session =
+ crate::agent_resume::AgentSessionRef::id("opencode-attached-client").unwrap();
+ let attached = terminal.set_hook_authority_with_session_ref(
+ "herdr:opencode".into(),
+ "opencode".into(),
+ AgentState::Working,
+ None,
+ Some(attached_session.clone()),
+ Some(21),
+ );
+ assert!(attached.is_none());
+ assert!(!terminal
+ .suppressed_full_lifecycle_hook_reports
+ .contains_key("herdr:opencode"));
- let mutation = terminal
+ let selected = terminal
.set_agent_session_ref_for_session_start(
"herdr:opencode".into(),
"opencode".into(),
- crate::agent_resume::AgentSessionRef::id("opencode-new"),
+ Some(selected_session.clone()),
+ None,
+ Some("select".into()),
+ )
+ .expect("selected session should replace the previous session");
+
+ assert!(selected.session_ref_changed);
+ assert!(terminal.hook_authority.is_none());
+ assert!(!terminal
+ .suppressed_full_lifecycle_hook_reports
+ .contains_key("herdr:opencode"));
+ assert_eq!(
+ terminal.hook_report_sequences.get("herdr:opencode"),
+ Some(&20)
+ );
+ assert_eq!(
+ terminal
+ .persisted_agent_session
+ .as_ref()
+ .map(|session| &session.session_ref),
+ Some(&selected_session)
+ );
+
+ terminal
+ .set_hook_authority_with_session_ref(
+ "herdr:opencode".into(),
+ "opencode".into(),
+ AgentState::Working,
+ None,
+ Some(selected_session.clone()),
Some(21),
- Some("new".into()),
)
- .expect("new should replace the session");
+ .expect("selected session should regain lifecycle authority");
+ assert_eq!(terminal.state, AgentState::Working);
+ assert_eq!(
+ terminal
+ .hook_authority
+ .as_ref()
+ .and_then(|authority| authority.session_ref.as_ref()),
+ Some(&selected_session)
+ );
- assert!(mutation.session_ref_changed);
+ let late_old_session = terminal.set_hook_authority_with_session_ref(
+ "herdr:opencode".into(),
+ "opencode".into(),
+ AgentState::Idle,
+ None,
+ Some(old_session),
+ Some(22),
+ );
+ assert!(late_old_session.is_none());
+ assert_eq!(terminal.state, AgentState::Working);
+ assert_eq!(
+ terminal
+ .hook_authority
+ .as_ref()
+ .and_then(|authority| authority.session_ref.as_ref()),
+ Some(&selected_session)
+ );
+
+ let late_attached_session = terminal.set_hook_authority_with_session_ref(
+ "herdr:opencode".into(),
+ "opencode".into(),
+ AgentState::Blocked,
+ None,
+ Some(attached_session),
+ Some(23),
+ );
+ assert!(late_attached_session.is_none());
+ assert_eq!(terminal.state, AgentState::Working);
+
+ let final_session =
+ crate::agent_resume::AgentSessionRef::id("opencode-final-selection").unwrap();
+ terminal
+ .set_agent_session_ref_for_session_start(
+ "herdr:opencode".into(),
+ "opencode".into(),
+ Some(final_session.clone()),
+ None,
+ Some("select".into()),
+ )
+ .expect("another local selection should remain authoritative");
assert_eq!(
terminal
.persisted_agent_session
.as_ref()
- .map(|session| session.session_ref.value.as_str()),
- Some("opencode-new")
+ .map(|session| &session.session_ref),
+ Some(&final_session)
);
+ assert!(!terminal
+ .suppressed_full_lifecycle_hook_reports
+ .contains_key("herdr:opencode"));
}
#[test]
@@ -4501,14 +4795,14 @@ mod tests {
terminal.set_detected_state(Some(Agent::OpenCode), AgentState::Idle);
assert!(terminal.reconcile_managed_agent_at(now, false));
- for (sequence, session) in [(20, "opencode-old"), (21, "opencode-new")] {
+ for session in ["opencode-old", "opencode-new"] {
terminal
.set_agent_session_ref_for_session_start(
"herdr:opencode".into(),
"opencode".into(),
crate::agent_resume::AgentSessionRef::id(session),
- Some(sequence),
- Some("new".into()),
+ None,
+ Some("select".into()),
)
.expect("managed session should be accepted");
}
@@ -4533,10 +4827,10 @@ mod tests {
"herdr:opencode".into(),
"opencode".into(),
crate::agent_resume::AgentSessionRef::id("opencode-old"),
- Some(20),
- Some("new".into()),
+ None,
+ Some("select".into()),
)
- .expect("initial session should be accepted");
+ .expect("local selection should be accepted");
// session.updated reports carry no session_start_source, so a different
// id must not displace the established session (cross-talk guard).
@@ -4793,10 +5087,10 @@ mod tests {
"herdr:opencode".into(),
"opencode".into(),
crate::agent_resume::AgentSessionRef::id("opencode-new-session"),
- Some(23),
- Some("new".into()),
+ None,
+ Some("select".into()),
)
- .expect("fresh root session");
+ .expect("fresh local selection");
let fresh_session = terminal.set_hook_authority_with_session_ref(
"herdr:opencode".into(),
"opencode".into(),