feat: add omp integration

This commit is contained in:
Ogulcan Celik 2026-05-23 15:04:14 +03:00
parent 5954ece9f7
commit ed11779fca
9 changed files with 567 additions and 7 deletions

View File

@ -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.

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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:

View File

@ -394,6 +394,7 @@ pub struct IntegrationUninstallParams {
#[serde(rename_all = "snake_case")]
pub enum IntegrationTarget {
Pi,
Omp,
Claude,
Codex,
Opencode,

View File

@ -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");

View File

@ -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();
});
}

View File

@ -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<PathBuf>,
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<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"])
}
@ -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();