From ed11779fcaebea7cc3223c4e221ea43674057891 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Sat, 23 May 2026 15:04:14 +0300 Subject: [PATCH] feat: add omp integration --- docs/next/CHANGELOG.md | 1 + docs/next/README.md | 5 +- docs/next/website/src/content/docs/agents.mdx | 1 + .../src/content/docs/cli-reference.mdx | 2 + .../website/src/content/docs/integrations.mdx | 22 +- src/api/schema.rs | 1 + src/cli/integration.rs | 9 +- .../assets/omp/herdr-agent-state.ts | 289 ++++++++++++++++++ src/integration/mod.rs | 244 ++++++++++++++- 9 files changed, 567 insertions(+), 7 deletions(-) create mode 100644 src/integration/assets/omp/herdr-agent-state.ts diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index 06bfd226..571db760 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added - Added `terminal.new_cwd` to choose whether new panes, tabs, and workspaces follow the source pane/workspace, start in `$HOME`, use Herdr's process directory, or use a fixed path. +- Added `herdr integration install omp` for OMP's `.omp` extension directory. The extension reports OMP pane state through Herdr's socket API without relying on native `omp` process detection. ### Fixed - Remote clients using local keybindings no longer show stale server keybinding warnings from the remote host. diff --git a/docs/next/README.md b/docs/next/README.md index 63a32ca7..16a9fd1c 100644 --- a/docs/next/README.md +++ b/docs/next/README.md @@ -191,10 +191,11 @@ for agents outside the built-in list, herdr still works as a terminal multiplexe ### direct integrations -the built-in pi, claude code, codex, opencode, and hermes integrations forward semantic state to herdr over the socket api. install with: +the built-in pi, omp, claude code, codex, opencode, and hermes integrations forward semantic state to herdr over the socket api. install with: ```bash herdr integration install pi +herdr integration install omp herdr integration install claude herdr integration install codex herdr integration install opencode @@ -277,7 +278,7 @@ full logging and environment variable details: [configuration docs](https://herd ## docs - [configuration](https://herdr.dev/docs/configuration/) — keybindings, themes, notifications, environment variables -- [integrations](https://herdr.dev/docs/integrations/) — pi, claude code, codex, opencode, hermes integrations +- [integrations](https://herdr.dev/docs/integrations/) — pi, omp, claude code, codex, opencode, hermes integrations - [`SKILL.md`](./SKILL.md) — reusable agent skill - [socket api](https://herdr.dev/docs/socket-api/) — socket protocol and cli reference diff --git a/docs/next/website/src/content/docs/agents.mdx b/docs/next/website/src/content/docs/agents.mdx index 559af389..d6b3f7f2 100644 --- a/docs/next/website/src/content/docs/agents.mdx +++ b/docs/next/website/src/content/docs/agents.mdx @@ -53,6 +53,7 @@ Install integrations for agents that expose hooks or plugins: ```bash herdr integration install pi +herdr integration install omp herdr integration install claude herdr integration install codex herdr integration install opencode diff --git a/docs/next/website/src/content/docs/cli-reference.mdx b/docs/next/website/src/content/docs/cli-reference.mdx index 6f10add3..5425c4a5 100644 --- a/docs/next/website/src/content/docs/cli-reference.mdx +++ b/docs/next/website/src/content/docs/cli-reference.mdx @@ -165,11 +165,13 @@ Use `wait output` for normal commands and servers. Use `wait agent-status` for c ```bash herdr integration install pi +herdr integration install omp herdr integration install claude herdr integration install codex herdr integration install opencode herdr integration install hermes herdr integration uninstall pi +herdr integration uninstall omp herdr integration uninstall claude herdr integration uninstall codex herdr integration uninstall opencode diff --git a/docs/next/website/src/content/docs/integrations.mdx b/docs/next/website/src/content/docs/integrations.mdx index 18b759bc..8c6443b8 100644 --- a/docs/next/website/src/content/docs/integrations.mdx +++ b/docs/next/website/src/content/docs/integrations.mdx @@ -1,6 +1,6 @@ --- title: Integrations -description: Install Herdr integrations for Pi, Claude Code, Codex, OpenCode, and Hermes Agent. +description: Install Herdr integrations for Pi, OMP, Claude Code, Codex, OpenCode, and Hermes Agent. --- Herdr detects supported agents automatically. Integrations make that detection more precise by reporting semantic state directly to Herdr. @@ -13,6 +13,7 @@ Open settings inside Herdr and use the integrations tab to install recommended i ```bash herdr integration install pi +herdr integration install omp herdr integration install claude herdr integration install codex herdr integration install opencode @@ -23,6 +24,7 @@ herdr integration install hermes ```bash herdr integration uninstall pi +herdr integration uninstall omp herdr integration uninstall claude herdr integration uninstall codex herdr integration uninstall opencode @@ -57,6 +59,24 @@ Herdr writes the bundled extension to: If `PI_CODING_AGENT_DIR` is set, Herdr writes to `$PI_CODING_AGENT_DIR/extensions/herdr-agent-state.ts` instead. The extensions directory must already exist. Uninstall removes only that extension file. +## OMP + +Install the OMP integration: + +```bash +herdr integration install omp +``` + +Herdr writes the bundled extension to: + +```text +~/.omp/agent/extensions/herdr-omp-agent-state.ts +``` + +If `PI_CODING_AGENT_DIR` is set, Herdr writes to `$PI_CODING_AGENT_DIR/extensions/herdr-omp-agent-state.ts` instead. The extensions directory must already exist. Uninstall removes only that extension file. + +The OMP integration reports `omp` as the agent label through Herdr's socket API. It does not require native process detection for the `omp` executable. + ## Claude Code Install the Claude Code hook: diff --git a/src/api/schema.rs b/src/api/schema.rs index 7cf73cda..7224d419 100644 --- a/src/api/schema.rs +++ b/src/api/schema.rs @@ -394,6 +394,7 @@ pub struct IntegrationUninstallParams { #[serde(rename_all = "snake_case")] pub enum IntegrationTarget { Pi, + Omp, Claude, Codex, Opencode, diff --git a/src/cli/integration.rs b/src/cli/integration.rs index 6239faf2..4814d70d 100644 --- a/src/cli/integration.rs +++ b/src/cli/integration.rs @@ -102,23 +102,24 @@ fn parse_integration_target( action: &str, ) -> std::io::Result> { let Some(target) = args.first().map(|arg| arg.as_str()) else { - eprintln!("usage: herdr integration {action} "); + eprintln!("usage: herdr integration {action} "); return Ok(None); }; if args.len() != 1 { - eprintln!("usage: herdr integration {action} "); + eprintln!("usage: herdr integration {action} "); return Ok(None); } let parsed = match target { "pi" => IntegrationTarget::Pi, + "omp" => IntegrationTarget::Omp, "claude" => IntegrationTarget::Claude, "codex" => IntegrationTarget::Codex, "opencode" => IntegrationTarget::Opencode, "hermes" => IntegrationTarget::Hermes, _ => { eprintln!("unknown integration target: {target}"); - eprintln!("currently supported: pi, claude, codex, opencode, hermes"); + eprintln!("currently supported: pi, omp, claude, codex, opencode, hermes"); return Ok(None); } }; @@ -129,11 +130,13 @@ fn parse_integration_target( fn print_integration_help() { eprintln!("herdr integration commands:"); eprintln!(" herdr integration install pi"); + eprintln!(" herdr integration install omp"); eprintln!(" herdr integration install claude"); eprintln!(" herdr integration install codex"); eprintln!(" herdr integration install opencode"); eprintln!(" herdr integration install hermes"); eprintln!(" herdr integration uninstall pi"); + eprintln!(" herdr integration uninstall omp"); eprintln!(" herdr integration uninstall claude"); eprintln!(" herdr integration uninstall codex"); eprintln!(" herdr integration uninstall opencode"); diff --git a/src/integration/assets/omp/herdr-agent-state.ts b/src/integration/assets/omp/herdr-agent-state.ts new file mode 100644 index 00000000..64c4b02a --- /dev/null +++ b/src/integration/assets/omp/herdr-agent-state.ts @@ -0,0 +1,289 @@ +// installed by herdr +// safe to edit. this integration only activates inside herdr-managed panes. +// HERDR_INTEGRATION_ID=omp +// HERDR_INTEGRATION_VERSION=1 +// @ts-nocheck + +import { createConnection } from "node:net"; + +const HERDR_ENV = process.env.HERDR_ENV; +const socketPath = process.env.HERDR_SOCKET_PATH; +const paneId = process.env.HERDR_PANE_ID; +const source = "herdr:omp"; + +function enabled() { + return HERDR_ENV === "1" && !!socketPath && !!paneId; +} + +function sendRequest(request: unknown): Promise { + if (!enabled()) { + return Promise.resolve(); + } + + return new Promise((resolve) => { + let done = false; + const finish = () => { + if (done) return; + done = true; + socket.destroy(); + resolve(); + }; + + const socket = createConnection(socketPath!); + socket.on("error", finish); + socket.on("connect", () => socket.write(`${JSON.stringify(request)}\n`)); + socket.on("data", finish); + socket.on("end", finish); + const timeout = setTimeout(finish, 500); + timeout.unref?.(); + }); +} + +type AgentState = "working" | "blocked" | "idle"; + +type QueuedState = { + state: AgentState; + message?: string; + seq: number; +}; + +const idleDebounceMs = parseDurationEnv("HERDR_OMP_IDLE_DEBOUNCE_MS", 250); +const retryGraceMs = parseDurationEnv("HERDR_OMP_RETRY_GRACE_MS", 2500); +const retryableErrorPattern = + /overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|network.?error|connection.?error|connection.?refused|connection.?lost|websocket.?closed|websocket.?error|other side closed|fetch failed|upstream.?connect|reset before headers|socket hang up|ended without|http2 request did not get a response|timed? out|timeout|terminated|retry delay/i; +let reportSeq = Date.now() * 1000; + +function nextReportSeq(): number { + reportSeq += 1; + return reportSeq; +} + +function parseDurationEnv(name: string, fallback: number): number { + const raw = process.env[name]; + if (!raw) { + return fallback; + } + const parsed = Number.parseInt(raw, 10); + if (!Number.isFinite(parsed) || parsed < 0) { + return fallback; + } + return parsed; +} + +function sendState(state: AgentState, message?: string, seq = nextReportSeq()): Promise { + return sendRequest({ + id: `${source}:${Date.now()}:${Math.random().toString(36).slice(2)}`, + method: "pane.report_agent", + params: { + pane_id: paneId, + source, + agent: "omp", + state, + message, + seq, + }, + }); +} + +let sendInFlight = false; +let queuedState: QueuedState | undefined; + +function queueState(state: AgentState, message?: string): void { + queuedState = { state, message, seq: nextReportSeq() }; + if (!sendInFlight) { + void drainStateQueue(); + } +} + +async function drainStateQueue(): Promise { + if (sendInFlight) { + return; + } + + sendInFlight = true; + try { + while (queuedState) { + const next = queuedState; + queuedState = undefined; + await sendState(next.state, next.message, next.seq); + } + } finally { + sendInFlight = false; + if (queuedState) { + void drainStateQueue(); + } + } +} + +function lastAssistantMessage(messages: unknown[]): any | undefined { + for (let i = messages.length - 1; i >= 0; i -= 1) { + const message = messages[i] as any; + if (message?.role === "assistant") { + return message; + } + } + return undefined; +} + +function retryableErrorMessage(event: any): string | undefined { + const messages = Array.isArray(event?.messages) ? event.messages : []; + const assistant = lastAssistantMessage(messages); + if (assistant?.stopReason !== "error") { + return undefined; + } + + const errorMessage = String(assistant.errorMessage ?? ""); + if (!retryableErrorPattern.test(errorMessage)) { + return undefined; + } + return errorMessage || "retryable provider error"; +} + +function releaseAgent(): Promise { + return sendRequest({ + id: `${source}:release:${Date.now()}:${Math.random().toString(36).slice(2)}`, + method: "pane.release_agent", + params: { + pane_id: paneId, + source, + agent: "omp", + seq: nextReportSeq(), + }, + }); +} + +export default function (pi) { + if (!enabled()) { + return; + } + + let agentActive = false; + let retryHoldActive = false; + let failureBlocked = false; + let failureMessage: string | undefined; + let blockedCount = 0; + let blockedMessage: string | undefined; + let lastState: AgentState | undefined; + let lastMessage: string | undefined; + let idleTimer: ReturnType | undefined; + let retryTimer: ReturnType | undefined; + + function clearTimer(timer: ReturnType | undefined) { + if (timer) { + clearTimeout(timer); + } + } + + function clearPendingTimers() { + clearTimer(idleTimer); + clearTimer(retryTimer); + idleTimer = undefined; + retryTimer = undefined; + } + + function clearFailureState() { + retryHoldActive = false; + failureBlocked = false; + failureMessage = undefined; + } + + function desiredState() { + if (blockedCount > 0) { + return { state: "blocked" as const, message: blockedMessage }; + } + if (failureBlocked) { + return { state: "blocked" as const, message: failureMessage }; + } + if (agentActive || retryHoldActive) { + return { state: "working" as const, message: undefined }; + } + return { state: "idle" as const, message: undefined }; + } + + function publishState() { + const next = desiredState(); + if (next.state === lastState && next.message === lastMessage) { + return; + } + lastState = next.state; + lastMessage = next.message; + queueState(next.state, next.message); + } + + function scheduleIdle() { + clearPendingTimers(); + clearFailureState(); + idleTimer = setTimeout(() => { + idleTimer = undefined; + publishState(); + }, idleDebounceMs); + idleTimer.unref?.(); + } + + function holdForRetry(message: string) { + clearPendingTimers(); + retryHoldActive = true; + failureBlocked = false; + failureMessage = message; + publishState(); + + retryTimer = setTimeout(() => { + retryTimer = undefined; + retryHoldActive = false; + failureBlocked = true; + publishState(); + }, retryGraceMs); + retryTimer.unref?.(); + } + + pi.events.on("herdr:blocked", (data) => { + if (!data?.active) { + blockedCount = Math.max(0, blockedCount - 1); + if (blockedCount === 0) { + blockedMessage = undefined; + } + publishState(); + return; + } + + clearPendingTimers(); + blockedCount += 1; + blockedMessage = data.label; + publishState(); + }); + + pi.on("session_start", () => { + publishState(); + }); + + pi.on("agent_start", () => { + clearPendingTimers(); + clearFailureState(); + agentActive = true; + publishState(); + }); + + pi.on("agent_end", (event) => { + if (!agentActive) { + // OMP can emit duplicate/late end events while auto-retry is already + // holding the pane in Working. Do not let an unqualified duplicate end + // cancel the retry hold and publish a false Idle. + return; + } + + agentActive = false; + + const retryableMessage = retryableErrorMessage(event); + if (retryableMessage) { + holdForRetry(retryableMessage); + return; + } + + scheduleIdle(); + }); + + pi.on("session_shutdown", async () => { + clearPendingTimers(); + await releaseAgent(); + }); +} diff --git a/src/integration/mod.rs b/src/integration/mod.rs index 8b6b854c..d6ccaa0f 100644 --- a/src/integration/mod.rs +++ b/src/integration/mod.rs @@ -13,6 +13,9 @@ pub(crate) const HERDR_PANE_ID_ENV_VAR: &str = "HERDR_PANE_ID"; const PI_EXTENSION_INSTALL_NAME: &str = "herdr-agent-state.ts"; const PI_EXTENSION_ASSET: &str = include_str!("assets/pi/herdr-agent-state.ts"); const PI_INTEGRATION_VERSION: u32 = 1; +const OMP_EXTENSION_INSTALL_NAME: &str = "herdr-omp-agent-state.ts"; +const OMP_EXTENSION_ASSET: &str = include_str!("assets/omp/herdr-agent-state.ts"); +const OMP_INTEGRATION_VERSION: u32 = 1; const PI_CODING_AGENT_DIR_ENV_VAR: &str = "PI_CODING_AGENT_DIR"; const CLAUDE_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh"; const CLAUDE_HOOK_ASSET: &str = include_str!("assets/claude/herdr-agent-state.sh"); @@ -51,6 +54,12 @@ pub(crate) struct OpenCodeInstallPaths { pub plugin_path: PathBuf, } +#[derive(Debug)] +pub(crate) struct OmpInstallPaths { + pub extension_path: PathBuf, + pub removed_legacy_pi_extension: bool, +} + #[derive(Debug)] pub(crate) struct HermesInstallPaths { pub plugin_dir: PathBuf, @@ -105,6 +114,12 @@ pub(crate) struct PiUninstallResult { pub removed_extension: bool, } +#[derive(Debug)] +pub(crate) struct OmpUninstallResult { + pub extension_path: PathBuf, + pub removed_extension: bool, +} + #[derive(Debug)] pub(crate) struct ClaudeUninstallResult { pub hook_path: PathBuf, @@ -149,6 +164,24 @@ pub(crate) fn install_target( let path = install_pi()?; vec![format!("installed pi integration to {}", path.display())] } + crate::api::schema::IntegrationTarget::Omp => { + let installed = install_omp()?; + let mut messages = Vec::new(); + if installed.removed_legacy_pi_extension { + messages.push(format!( + "removed legacy pi integration from omp extension directory at {}", + installed + .extension_path + .with_file_name(PI_EXTENSION_INSTALL_NAME) + .display() + )); + } + messages.push(format!( + "installed omp integration to {}", + installed.extension_path.display() + )); + messages + } crate::api::schema::IntegrationTarget::Claude => { let installed = install_claude()?; vec![ @@ -220,6 +253,20 @@ pub(crate) fn uninstall_target( )] } } + crate::api::schema::IntegrationTarget::Omp => { + let result = uninstall_omp()?; + if result.removed_extension { + vec![format!( + "removed omp integration extension at {}", + result.extension_path.display() + )] + } else { + vec![format!( + "no omp integration extension found at {}", + result.extension_path.display() + )] + } + } crate::api::schema::IntegrationTarget::Claude => { let result = uninstall_claude()?; let mut messages = Vec::new(); @@ -330,6 +377,7 @@ pub(crate) fn integration_target_label( ) -> &'static str { match target { crate::api::schema::IntegrationTarget::Pi => "pi", + crate::api::schema::IntegrationTarget::Omp => "omp", crate::api::schema::IntegrationTarget::Claude => "claude", crate::api::schema::IntegrationTarget::Codex => "codex", crate::api::schema::IntegrationTarget::Opencode => "opencode", @@ -340,6 +388,7 @@ pub(crate) fn integration_target_label( fn integration_target_command(target: crate::api::schema::IntegrationTarget) -> &'static str { match target { crate::api::schema::IntegrationTarget::Pi => "pi", + crate::api::schema::IntegrationTarget::Omp => "omp", crate::api::schema::IntegrationTarget::Claude => "claude", crate::api::schema::IntegrationTarget::Codex => "codex", crate::api::schema::IntegrationTarget::Opencode => "opencode", @@ -417,13 +466,18 @@ fn integration_specs() -> [( crate::api::schema::IntegrationTarget, io::Result, u32, -); 5] { +); 6] { [ ( crate::api::schema::IntegrationTarget::Pi, pi_extension_dir().map(|dir| dir.join(PI_EXTENSION_INSTALL_NAME)), PI_INTEGRATION_VERSION, ), + ( + crate::api::schema::IntegrationTarget::Omp, + omp_extension_dir().map(|dir| dir.join(OMP_EXTENSION_INSTALL_NAME)), + OMP_INTEGRATION_VERSION, + ), ( crate::api::schema::IntegrationTarget::Claude, claude_dir().map(|dir| dir.join("hooks").join(CLAUDE_HOOK_INSTALL_NAME)), @@ -546,6 +600,39 @@ pub(crate) fn install_pi() -> io::Result { Ok(path) } +pub(crate) fn install_omp() -> io::Result { + let dir = omp_extension_dir()?; + if !dir.is_dir() { + return Err(io::Error::other(format!( + "omp extension directory not found at {}. install omp and create the extensions directory first", + dir.display() + ))); + } + + let removed_legacy_pi_extension = remove_legacy_pi_extension_from_omp_dir(&dir)?; + let extension_path = dir.join(OMP_EXTENSION_INSTALL_NAME); + fs::write(&extension_path, OMP_EXTENSION_ASSET)?; + Ok(OmpInstallPaths { + extension_path, + removed_legacy_pi_extension, + }) +} + +fn remove_legacy_pi_extension_from_omp_dir(dir: &Path) -> io::Result { + let legacy_path = dir.join(PI_EXTENSION_INSTALL_NAME); + if !legacy_path.is_file() { + return Ok(false); + } + + let content = fs::read_to_string(&legacy_path)?; + if content.contains("HERDR_INTEGRATION_ID=pi") { + fs::remove_file(legacy_path)?; + return Ok(true); + } + + Ok(false) +} + pub(crate) fn install_claude() -> io::Result { let dir = claude_dir()?; if !dir.is_dir() { @@ -790,6 +877,16 @@ pub(crate) fn uninstall_pi() -> io::Result { }) } +pub(crate) fn uninstall_omp() -> io::Result { + let extension_path = omp_extension_dir()?.join(OMP_EXTENSION_INSTALL_NAME); + let removed_extension = remove_file_if_exists(&extension_path)?; + + Ok(OmpUninstallResult { + extension_path, + removed_extension, + }) +} + pub(crate) fn uninstall_claude() -> io::Result { let hook_path = claude_dir()?.join("hooks").join(CLAUDE_HOOK_INSTALL_NAME); let settings_path = claude_dir()?.join("settings.json"); @@ -1355,6 +1452,13 @@ fn pi_extension_dir() -> io::Result { ) } +fn omp_extension_dir() -> io::Result { + Ok( + config_dir_from_env_or_home(PI_CODING_AGENT_DIR_ENV_VAR, &[".omp", "agent"])? + .join("extensions"), + ) +} + fn claude_dir() -> io::Result { config_dir_from_env_or_home(CLAUDE_CONFIG_DIR_ENV_VAR, &[".claude"]) } @@ -1553,6 +1657,144 @@ mod tests { let _ = fs::remove_dir_all(base); } + #[test] + fn install_omp_writes_embedded_asset_to_omp_extensions_dir() { + let _lock = integration_env_lock(); + let base = unique_base(); + let home = base.join("home"); + let ext_dir = home.join(".omp/agent/extensions"); + fs::create_dir_all(&ext_dir).unwrap(); + std::env::set_var("HOME", &home); + + let installed = install_omp().unwrap(); + let content = fs::read_to_string(&installed.extension_path).unwrap(); + + assert_eq!( + installed.extension_path, + ext_dir.join(OMP_EXTENSION_INSTALL_NAME) + ); + assert!(!installed.removed_legacy_pi_extension); + assert_eq!(content, OMP_EXTENSION_ASSET); + + std::env::remove_var("HOME"); + let _ = fs::remove_dir_all(base); + } + + #[test] + fn install_omp_removes_legacy_pi_integration_from_omp_extensions_dir() { + let _lock = integration_env_lock(); + let base = unique_base(); + let home = base.join("home"); + let ext_dir = home.join(".omp/agent/extensions"); + fs::create_dir_all(&ext_dir).unwrap(); + let legacy_path = ext_dir.join(PI_EXTENSION_INSTALL_NAME); + fs::write(&legacy_path, PI_EXTENSION_ASSET).unwrap(); + std::env::set_var("HOME", &home); + + let installed = install_omp().unwrap(); + + assert_eq!( + installed.extension_path, + ext_dir.join(OMP_EXTENSION_INSTALL_NAME) + ); + assert!(installed.removed_legacy_pi_extension); + assert!(!legacy_path.exists()); + + std::env::remove_var("HOME"); + let _ = fs::remove_dir_all(base); + } + + #[test] + fn install_omp_preserves_non_herdr_file_with_pi_install_name() { + let _lock = integration_env_lock(); + let base = unique_base(); + let home = base.join("home"); + let ext_dir = home.join(".omp/agent/extensions"); + fs::create_dir_all(&ext_dir).unwrap(); + let user_path = ext_dir.join(PI_EXTENSION_INSTALL_NAME); + fs::write(&user_path, "// user extension\n").unwrap(); + std::env::set_var("HOME", &home); + + let installed = install_omp().unwrap(); + + assert_eq!( + installed.extension_path, + ext_dir.join(OMP_EXTENSION_INSTALL_NAME) + ); + assert!(!installed.removed_legacy_pi_extension); + assert_eq!( + fs::read_to_string(user_path).unwrap(), + "// user extension\n" + ); + + std::env::remove_var("HOME"); + let _ = fs::remove_dir_all(base); + } + + #[test] + fn install_omp_uses_pi_coding_agent_dir_env() { + let _lock = integration_env_lock(); + let base = unique_base(); + let agent_dir = base.join("custom-omp-agent"); + let ext_dir = agent_dir.join("extensions"); + fs::create_dir_all(&ext_dir).unwrap(); + std::env::set_var(PI_CODING_AGENT_DIR_ENV_VAR, &agent_dir); + + let installed = install_omp().unwrap(); + + assert_eq!( + installed.extension_path, + ext_dir.join(OMP_EXTENSION_INSTALL_NAME) + ); + assert!(!installed.removed_legacy_pi_extension); + + clear_integration_path_env(); + let _ = fs::remove_dir_all(base); + } + + #[test] + fn uninstall_omp_removes_embedded_extension_when_present() { + let _lock = integration_env_lock(); + let base = unique_base(); + let home = base.join("home"); + let ext_dir = home.join(".omp/agent/extensions"); + fs::create_dir_all(&ext_dir).unwrap(); + fs::write( + ext_dir.join(OMP_EXTENSION_INSTALL_NAME), + OMP_EXTENSION_ASSET, + ) + .unwrap(); + std::env::set_var("HOME", &home); + + let result = uninstall_omp().unwrap(); + + assert_eq!( + result.extension_path, + ext_dir.join(OMP_EXTENSION_INSTALL_NAME) + ); + assert!(result.removed_extension); + assert!(!result.extension_path.exists()); + + std::env::remove_var("HOME"); + let _ = fs::remove_dir_all(base); + } + + #[test] + fn install_omp_errors_when_extension_dir_missing() { + let _lock = integration_env_lock(); + let base = unique_base(); + let home = base.join("home"); + fs::create_dir_all(&home).unwrap(); + std::env::set_var("HOME", &home); + + let err = install_omp().unwrap_err().to_string(); + + assert!(err.contains("omp extension directory not found")); + + std::env::remove_var("HOME"); + let _ = fs::remove_dir_all(base); + } + #[test] fn uninstall_pi_removes_embedded_extension_when_present() { let _lock = integration_env_lock();