mirror of
https://github.com/herdrdev/herdr.git
synced 2026-09-22 00:01:06 +00:00
fix: track opencode tui session selection (#2455)
refs #2450 Co-authored-by: Ogulcan Celik <ogulcancelik@gmail.com>
This commit is contained in:
@@ -18,6 +18,7 @@
|
||||
- Experimental pane graphics now support bounded named layers, acknowledged full-RGBA primary-layer direct file frames on audited local terminals, owned BGRA fallback, exact pixel mouse input, and placement-only resize replay.
|
||||
|
||||
### Fixed
|
||||
- OpenCode panes now track the root conversation selected in their own TUI for native restore without adopting activity from attached clients. (#2450)
|
||||
- Fish `Ctrl+Alt` keybindings now work in panes after legacy Alt-prefixed control bytes are decoded with both modifiers. (#2514)
|
||||
- `herdr config check` now reports unknown built-in theme names instead of silently accepting them. (#2452)
|
||||
- macOS `herdr --remote` clients now keep the accepted bridge socket blocking, preventing an immediate disconnect after the protocol handshake. (#2478, thanks @mathijshenquet)
|
||||
|
||||
@@ -75,6 +75,7 @@ website-build:
|
||||
integration-assets-test:
|
||||
bun test src/integration/assets/herdr-agent-state.test.ts
|
||||
bun test src/integration/assets/opencode/herdr-agent-state.test.ts
|
||||
bun test src/integration/assets/opencode/herdr-tui-session.test.ts
|
||||
|
||||
# Run plugin marketplace Worker tests
|
||||
plugin-marketplace-test:
|
||||
|
||||
+5
-1
@@ -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,
|
||||
@@ -608,6 +608,10 @@ mod tests {
|
||||
normalize_session_start_source(Some("fork".into())),
|
||||
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())
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+105
-10
@@ -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);
|
||||
|
||||
@@ -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)]
|
||||
|
||||
+329
-35
@@ -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,39 +4485,274 @@ 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"),
|
||||
Some(20),
|
||||
Some("new".into()),
|
||||
crate::agent_resume::AgentSessionRef::id("opencode-visible"),
|
||||
None,
|
||||
Some("select".into()),
|
||||
)
|
||||
.expect("initial session should be accepted");
|
||||
.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-new"),
|
||||
Some(21),
|
||||
Some("new".into()),
|
||||
)
|
||||
.expect("new should replace the session");
|
||||
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.session_ref_changed);
|
||||
assert!(mutation.is_none());
|
||||
assert_eq!(
|
||||
terminal
|
||||
.persisted_agent_session
|
||||
.as_ref()
|
||||
.map(|session| session.session_ref.value.as_str()),
|
||||
Some("opencode-new")
|
||||
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),
|
||||
)
|
||||
.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 selected = terminal
|
||||
.set_agent_session_ref_for_session_start(
|
||||
"herdr:opencode".into(),
|
||||
"opencode".into(),
|
||||
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),
|
||||
)
|
||||
.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)
|
||||
);
|
||||
|
||||
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),
|
||||
Some(&final_session)
|
||||
);
|
||||
assert!(!terminal
|
||||
.suppressed_full_lifecycle_hook_reports
|
||||
.contains_key("herdr:opencode"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pi_session_replacement_clears_the_previous_sessions_alias() {
|
||||
let mut terminal = test_terminal();
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user