aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/api/schema.rs1
-rw-r--r--src/cli/integration.rs9
-rw-r--r--src/integration/assets/omp/herdr-agent-state.ts289
-rw-r--r--src/integration/mod.rs244
4 files changed, 539 insertions, 4 deletions
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<Option<IntegrationTarget>> {
let Some(target) = args.first().map(|arg| arg.as_str()) else {
- eprintln!("usage: herdr integration {action} <pi|claude|codex|opencode|hermes>");
+ eprintln!("usage: herdr integration {action} <pi|omp|claude|codex|opencode|hermes>");
return Ok(None);
};
if args.len() != 1 {
- eprintln!("usage: herdr integration {action} <pi|claude|codex|opencode|hermes>");
+ eprintln!("usage: herdr integration {action} <pi|omp|claude|codex|opencode|hermes>");
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<void> {
+ 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<void> {
+ 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<void> {
+ 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<void> {
+ 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<typeof setTimeout> | undefined;
+ let retryTimer: ReturnType<typeof setTimeout> | undefined;
+
+ function clearTimer(timer: ReturnType<typeof setTimeout> | 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");
@@ -52,6 +55,12 @@ pub(crate) struct OpenCodeInstallPaths {
}
#[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,
pub config_path: PathBuf,
@@ -106,6 +115,12 @@ pub(crate) struct PiUninstallResult {
}
#[derive(Debug)]
+pub(crate) struct OmpUninstallResult {
+ pub extension_path: PathBuf,
+ pub removed_extension: bool,
+}
+
+#[derive(Debug)]
pub(crate) struct ClaudeUninstallResult {
pub hook_path: PathBuf,
pub settings_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,7 +466,7 @@ fn integration_specs() -> [(
crate::api::schema::IntegrationTarget,
io::Result<PathBuf>,
u32,
-); 5] {
+); 6] {
[
(
crate::api::schema::IntegrationTarget::Pi,
@@ -425,6 +474,11 @@ fn integration_specs() -> [(
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)),
CLAUDE_INTEGRATION_VERSION,
@@ -546,6 +600,39 @@ pub(crate) fn install_pi() -> io::Result<PathBuf> {
Ok(path)
}
+pub(crate) fn install_omp() -> io::Result<OmpInstallPaths> {
+ 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<bool> {
+ 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<ClaudeInstallPaths> {
let dir = claude_dir()?;
if !dir.is_dir() {
@@ -790,6 +877,16 @@ pub(crate) fn uninstall_pi() -> io::Result<PiUninstallResult> {
})
}
+pub(crate) fn uninstall_omp() -> io::Result<OmpUninstallResult> {
+ 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<ClaudeUninstallResult> {
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<PathBuf> {
)
}
+fn omp_extension_dir() -> io::Result<PathBuf> {
+ Ok(
+ config_dir_from_env_or_home(PI_CODING_AGENT_DIR_ENV_VAR, &[".omp", "agent"])?
+ .join("extensions"),
+ )
+}
+
fn claude_dir() -> io::Result<PathBuf> {
config_dir_from_env_or_home(CLAUDE_CONFIG_DIR_ENV_VAR, &[".claude"])
}
@@ -1554,6 +1658,144 @@ mod tests {
}
#[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();
let base = unique_base();