feat: add claude, codex, and opencode integrations

This commit is contained in:
Can Celik 2026-04-01 21:00:09 +03:00
parent ca4270b31f
commit 28fb23cdaf
8 changed files with 1603 additions and 9 deletions

198
INTEGRATIONS.md Normal file
View File

@ -0,0 +1,198 @@
# integrations
herdr works without any hook or plugin setup.
out of the box, it detects supported agents by combining foreground process detection with screen heuristics. that is enough to give you workspace awareness with zero configuration.
optional integrations improve the semantic signal on top of that.
## how herdr uses integrations
herdr uses a hybrid model:
- **process detection** owns pane identity, liveness, and "the process is gone"
- **agent integrations** report semantic state like `working`, `blocked`, and `idle` when the tool exposes those events
- **screen heuristics** remain the fallback for gaps, unsupported tools, or incomplete hook surfaces
that means hooks/plugins do **not** become the source of truth for pane ownership. they enrich state reporting; they do not replace process detection.
## install commands
```bash
herdr integration install pi
herdr integration install claude
herdr integration install codex
herdr integration install opencode
```
## uninstall commands
```bash
herdr integration uninstall pi
herdr integration uninstall claude
herdr integration uninstall codex
herdr integration uninstall opencode
```
## pi
install:
```bash
herdr integration install pi
```
this writes the bundled pi extension to:
```text
~/.pi/agent/extensions/herdr-agent-state.ts
```
pi is the cleanest integration. it already has an authoritative hook model, so herdr can get direct state reports without guessing as much from the terminal.
uninstall:
```bash
herdr integration uninstall pi
```
this removes:
```text
~/.pi/agent/extensions/herdr-agent-state.ts
```
## claude code
install:
```bash
herdr integration install claude
```
this:
- writes the hook script to `~/.claude/hooks/herdr-agent-state.sh`
- updates `~/.claude/settings.json`
current hook mapping:
- `UserPromptSubmit``working`
- `PreToolUse``working`
- `PermissionRequest``blocked`
- `Stop``idle`
- `SessionEnd``release`
notes:
- claude's current hook surface improves state reporting, but it is not a perfect permission lifecycle.
- when a permission prompt is canceled, claude does not currently give herdr a clean hook event that always resolves the pane out of `blocked` immediately.
- that is acceptable in herdr's model: process detection still owns liveness, and heuristics remain the fallback for unresolved edges.
uninstall:
```bash
herdr integration uninstall claude
```
this:
- removes `~/.claude/hooks/herdr-agent-state.sh`
- removes herdr-owned hook entries from `~/.claude/settings.json`
## codex
install:
```bash
herdr integration install codex
```
this:
- writes the hook script to `~/.codex/herdr-agent-state.sh`
- updates `~/.codex/hooks.json`
- ensures `codex_hooks = true` in `~/.codex/config.toml`
current hook mapping:
- `SessionStart``idle`
- `UserPromptSubmit``working`
- `PreToolUse``working`
- `Stop``idle`
notes:
- codex does **not** currently expose a permission-specific hook like claude or opencode, so `blocked` still depends on herdr's normal heuristics.
- codex currently renders hook lifecycle messages in its own tui, for example `Running SessionStart hook` and `SessionStart hook (completed)`.
- that noise is an upstream codex limitation, not a herdr-specific issue.
- codex has a `suppressOutput` field in its hook output schema, but it is currently not effective for suppressing those tui lifecycle lines.
uninstall:
```bash
herdr integration uninstall codex
```
this:
- removes `~/.codex/herdr-agent-state.sh`
- removes herdr-owned hook entries from `~/.codex/hooks.json`
- intentionally leaves `~/.codex/config.toml` alone
that last point is deliberate: herdr does **not** try to guess whether `codex_hooks = true` is still needed for some other codex hook setup.
## opencode
install:
```bash
herdr integration install opencode
```
this writes the bundled plugin to:
```text
~/.config/opencode/plugins/herdr-agent-state.js
```
current plugin mapping:
- `permission.asked``blocked`
- `permission.replied: once|always``working`
- `permission.replied: reject``idle`
- `session.status: busy|retry``working`
- `session.status: idle``idle`
- `session.idle``idle`
notes:
- opencode has the richest event surface of the currently supported integrations.
- herdr intentionally does **not** guess that `session.deleted` means process exit. process detection still owns liveness and pane identity.
uninstall:
```bash
herdr integration uninstall opencode
```
this removes:
```text
~/.config/opencode/plugins/herdr-agent-state.js
```
## known limitations
- these integrations only activate inside herdr-managed panes.
- if an agent has an incomplete hook surface, herdr falls back to process detection and screen heuristics rather than inventing lease or ttl behavior.
- codex currently shows hook lifecycle chatter in its own tui until upstream adds a real silent mode.
## troubleshooting
if an install command succeeds but you do not see improved state reporting:
1. make sure you launched the agent inside a herdr pane
2. restart the agent session so it picks up the new hook/plugin config
3. verify the expected config file was written to the path above
4. remember that unsupported transitions still fall back to herdr's built-in heuristics

View File

@ -7,7 +7,7 @@
<p align="center">herd your agents.</p>
<p align="center">
<a href="https://herdr.dev">herdr.dev</a> · <a href="#install">install</a> · <a href="#usage">usage</a> · <a href="./CONFIGURATION.md">configuration</a> · <a href="./SKILL.md">agent skill</a> · <a href="./SOCKET_API.md">socket api</a>
<a href="https://herdr.dev">herdr.dev</a> · <a href="#install">install</a> · <a href="#usage">usage</a> · <a href="./INTEGRATIONS.md">integrations</a> · <a href="./CONFIGURATION.md">configuration</a> · <a href="./SKILL.md">agent skill</a> · <a href="./SOCKET_API.md">socket api</a>
</p>
---
@ -239,10 +239,42 @@ this means detection works with any supported agent, installed any way, with zer
the heuristics are pattern-matched against each agent's actual terminal output: prompt boxes, spinners, waiting-for-input messages, tool execution indicators. detection runs on a separate async task per pane, polled every 300-500ms, decoupled from terminal rendering.
## optional direct integrations
herdr also supports optional direct integrations for tools that expose hooks or plugins:
- [pi](./INTEGRATIONS.md#pi)
- [claude code](./INTEGRATIONS.md#claude-code)
- [codex](./INTEGRATIONS.md#codex)
- [opencode](./INTEGRATIONS.md#opencode)
install them with:
```bash
herdr integration install pi
herdr integration install claude
herdr integration install codex
herdr integration install opencode
```
remove them with:
```bash
herdr integration uninstall pi
herdr integration uninstall claude
herdr integration uninstall codex
herdr integration uninstall opencode
```
these integrations improve semantic state reporting, but they do not replace herdr's core process detection model. for setup details, file locations, caveats, and uninstall behavior, see [`INTEGRATIONS.md`](./INTEGRATIONS.md).
known codex caveat: codex currently renders hook lifecycle lines in its own tui when hooks are enabled. that noise is upstream codex behavior, not herdr-specific.
## api and automation
for direct integration details, use the docs instead of reverse-engineering the README:
- [`INTEGRATIONS.md`](./INTEGRATIONS.md) — install and behavior notes for pi, claude code, codex, and opencode
- [`SKILL.md`](./SKILL.md) — reusable agent skill for agents already running inside herdr
- [`SOCKET_API.md`](./SOCKET_API.md) — canonical socket protocol + cli wrapper reference

View File

@ -99,6 +99,7 @@ fn run_integration_command(args: &[String]) -> std::io::Result<i32> {
match subcommand {
"install" => integration_install(&args[1..]),
"uninstall" => integration_uninstall(&args[1..]),
_ => {
print_integration_help();
Ok(2)
@ -450,11 +451,11 @@ fn pane_run(args: &[String]) -> std::io::Result<i32> {
fn integration_install(args: &[String]) -> std::io::Result<i32> {
let Some(target) = args.first().map(|arg| arg.as_str()) else {
eprintln!("usage: herdr integration install <pi>");
eprintln!("usage: herdr integration install <pi|claude|codex|opencode>");
return Ok(2);
};
if args.len() != 1 {
eprintln!("usage: herdr integration install <pi>");
eprintln!("usage: herdr integration install <pi|claude|codex|opencode>");
return Ok(2);
}
@ -464,9 +465,135 @@ fn integration_install(args: &[String]) -> std::io::Result<i32> {
println!("installed pi integration to {}", path.display());
Ok(0)
}
"claude" => {
let installed = crate::integration::install_claude()?;
println!(
"installed claude integration hook to {}",
installed.hook_path.display()
);
println!(
"ensured claude settings at {}",
installed.settings_path.display()
);
Ok(0)
}
"codex" => {
let installed = crate::integration::install_codex()?;
println!(
"installed codex integration hook to {}",
installed.hook_path.display()
);
println!("ensured codex hooks at {}", installed.hooks_path.display());
println!(
"ensured codex config at {}",
installed.config_path.display()
);
Ok(0)
}
"opencode" => {
let installed = crate::integration::install_opencode()?;
println!(
"installed opencode integration plugin to {}",
installed.plugin_path.display()
);
Ok(0)
}
_ => {
eprintln!("unknown integration target: {target}");
eprintln!("currently supported: pi");
eprintln!("currently supported: pi, claude, codex, opencode");
Ok(2)
}
}
}
fn integration_uninstall(args: &[String]) -> std::io::Result<i32> {
let Some(target) = args.first().map(|arg| arg.as_str()) else {
eprintln!("usage: herdr integration uninstall <pi|claude|codex|opencode>");
return Ok(2);
};
if args.len() != 1 {
eprintln!("usage: herdr integration uninstall <pi|claude|codex|opencode>");
return Ok(2);
}
match target {
"pi" => {
let result = crate::integration::uninstall_pi()?;
if result.removed_extension {
println!(
"removed pi integration extension at {}",
result.extension_path.display()
);
} else {
println!(
"no pi integration extension found at {}",
result.extension_path.display()
);
}
Ok(0)
}
"claude" => {
let result = crate::integration::uninstall_claude()?;
if result.removed_hook_file {
println!("removed claude hook at {}", result.hook_path.display());
} else {
println!("no claude hook found at {}", result.hook_path.display());
}
if result.updated_settings {
println!(
"removed herdr claude hook entries from {}",
result.settings_path.display()
);
} else {
println!(
"no herdr claude hook entries found in {}",
result.settings_path.display()
);
}
Ok(0)
}
"codex" => {
let result = crate::integration::uninstall_codex()?;
if result.removed_hook_file {
println!("removed codex hook at {}", result.hook_path.display());
} else {
println!("no codex hook found at {}", result.hook_path.display());
}
if result.updated_hooks {
println!(
"removed herdr codex hook entries from {}",
result.hooks_path.display()
);
} else {
println!(
"no herdr codex hook entries found in {}",
result.hooks_path.display()
);
}
println!(
"left codex config unchanged at {}",
result.config_path.display()
);
Ok(0)
}
"opencode" => {
let result = crate::integration::uninstall_opencode()?;
if result.removed_plugin {
println!(
"removed opencode integration plugin at {}",
result.plugin_path.display()
);
} else {
println!(
"no opencode integration plugin found at {}",
result.plugin_path.display()
);
}
Ok(0)
}
_ => {
eprintln!("unknown integration target: {target}");
eprintln!("currently supported: pi, claude, codex, opencode");
Ok(2)
}
}
@ -786,6 +913,13 @@ fn print_wait_help() {
fn print_integration_help() {
eprintln!("herdr integration commands:");
eprintln!(" herdr integration install pi");
eprintln!(" herdr integration install claude");
eprintln!(" herdr integration install codex");
eprintln!(" herdr integration install opencode");
eprintln!(" herdr integration uninstall pi");
eprintln!(" herdr integration uninstall claude");
eprintln!(" herdr integration uninstall codex");
eprintln!(" herdr integration uninstall opencode");
}
fn _print_json<T: Serialize>(value: &T) {

View File

@ -0,0 +1,70 @@
#!/bin/sh
# installed by herdr
# safe to edit. this hook only activates inside herdr-managed panes.
set -eu
action="${1:-}"
cat >/dev/null 2>/dev/null || true
case "$action" in
working|idle|blocked|release) ;;
*) exit 0 ;;
esac
[ "${HERDR_ENV:-}" = "1" ] || exit 0
[ -n "${HERDR_SOCKET_PATH:-}" ] || exit 0
[ -n "${HERDR_PANE_ID:-}" ] || exit 0
command -v python3 >/dev/null 2>&1 || exit 0
HERDR_ACTION="$action" python3 - <<'PY'
import json
import os
import random
import socket
import time
source = "herdr:claude"
action = os.environ.get("HERDR_ACTION", "")
pane_id = os.environ.get("HERDR_PANE_ID")
socket_path = os.environ.get("HERDR_SOCKET_PATH")
if not pane_id or not socket_path:
raise SystemExit(0)
request_id = f"{source}:{int(time.time() * 1000)}:{random.randrange(1_000_000):06d}"
if action == "release":
request = {
"id": request_id,
"method": "pane.release_agent",
"params": {
"pane_id": pane_id,
"source": source,
"agent": "claude",
},
}
else:
request = {
"id": request_id,
"method": "pane.report_agent",
"params": {
"pane_id": pane_id,
"source": source,
"agent": "claude",
"state": action,
},
}
try:
client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
client.settimeout(0.5)
client.connect(socket_path)
client.sendall((json.dumps(request) + "\n").encode())
try:
client.recv(4096)
except Exception:
pass
client.close()
except Exception:
pass
PY

View File

@ -0,0 +1,70 @@
#!/bin/sh
# installed by herdr
# safe to edit. this hook only activates inside herdr-managed panes.
set -eu
action="${1:-}"
cat >/dev/null 2>/dev/null || true
case "$action" in
working|idle|blocked|release) ;;
*) exit 0 ;;
esac
[ "${HERDR_ENV:-}" = "1" ] || exit 0
[ -n "${HERDR_SOCKET_PATH:-}" ] || exit 0
[ -n "${HERDR_PANE_ID:-}" ] || exit 0
command -v python3 >/dev/null 2>&1 || exit 0
HERDR_ACTION="$action" python3 - <<'PY'
import json
import os
import random
import socket
import time
source = "herdr:codex"
action = os.environ.get("HERDR_ACTION", "")
pane_id = os.environ.get("HERDR_PANE_ID")
socket_path = os.environ.get("HERDR_SOCKET_PATH")
if not pane_id or not socket_path:
raise SystemExit(0)
request_id = f"{source}:{int(time.time() * 1000)}:{random.randrange(1_000_000):06d}"
if action == "release":
request = {
"id": request_id,
"method": "pane.release_agent",
"params": {
"pane_id": pane_id,
"source": source,
"agent": "codex",
},
}
else:
request = {
"id": request_id,
"method": "pane.report_agent",
"params": {
"pane_id": pane_id,
"source": source,
"agent": "codex",
"state": action,
},
}
try:
client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
client.settimeout(0.5)
client.connect(socket_path)
client.sendall((json.dumps(request) + "\n").encode())
try:
client.recv(4096)
except Exception:
pass
client.close()
except Exception:
pass
PY

View File

@ -0,0 +1,99 @@
import net from "node:net";
const SOURCE = "herdr:opencode";
function reportState(action) {
const paneId = process.env.HERDR_PANE_ID;
const socketPath = process.env.HERDR_SOCKET_PATH;
if (!paneId || !socketPath) {
return Promise.resolve();
}
const requestId = `${SOURCE}:${Date.now()}:${Math.floor(Math.random() * 1_000_000)
.toString()
.padStart(6, "0")}`;
const request = {
id: requestId,
method: action === "release" ? "pane.release_agent" : "pane.report_agent",
params:
action === "release"
? {
pane_id: paneId,
source: SOURCE,
agent: "opencode",
}
: {
pane_id: paneId,
source: SOURCE,
agent: "opencode",
state: action,
},
};
return new Promise((resolve) => {
const client = net.createConnection(socketPath, () => {
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 const HerdrAgentStatePlugin = async () => {
if (
process.env.HERDR_ENV !== "1" ||
!process.env.HERDR_SOCKET_PATH ||
!process.env.HERDR_PANE_ID
) {
return {};
}
return {
event: async ({ event }) => {
const type = event?.type;
const properties = event?.properties ?? {};
switch (type) {
case "permission.asked":
await reportState("blocked");
break;
case "permission.replied": {
const reply = properties.reply ?? properties.response;
if (reply === "reject") {
await reportState("idle");
} else if (reply === "once" || reply === "always") {
await reportState("working");
}
break;
}
case "session.status": {
const status =
typeof properties.status === "string"
? properties.status
: properties.status?.type;
if (status === "busy" || status === "retry") {
await reportState("working");
} else if (status === "idle") {
await reportState("idle");
}
break;
}
case "session.idle":
await reportState("idle");
break;
default:
break;
}
},
};
};

File diff suppressed because it is too large Load Diff

View File

@ -195,7 +195,7 @@ fn main() -> io::Result<()> {
println!(" workspace workspace helpers over the socket api");
println!(" pane pane control helpers over the socket api");
println!(" wait blocking wait helpers over the socket api");
println!(" integration install built-in agent integrations");
println!(" integration manage built-in agent integrations");
println!();
println!("Options:");
println!(" --no-session Don't restore or save sessions");