aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorEric Yue <yue1995pro@gmail.com>2026-06-15 21:50:24 +0800
committerGitHub <noreply@github.com>2026-06-15 16:50:24 +0300
commit07261e09be60ac0c145685fd11f94351cc0e5ba6 (patch)
tree771b0180c7a1560189547e6d23ef004ec1cf6d5c
parent08bb0768ae224c8db04bbe016b050353ce13eee7 (diff)
feat: add devin cli detection and restore support (#622)
* feat: add devin cli detection and restore support refs #606 * fix: keep devin integration session-only --------- Co-authored-by: Ogulcan Celik <ogulcancelik@gmail.com>
-rw-r--r--docs/next/CHANGELOG.md4
-rw-r--r--docs/next/README.md6
-rw-r--r--docs/next/website/src/content/docs/agents.mdx1
-rw-r--r--docs/next/website/src/content/docs/cli-reference.mdx2
-rw-r--r--docs/next/website/src/content/docs/configuration.mdx6
-rw-r--r--docs/next/website/src/content/docs/integrations.mdx26
-rw-r--r--docs/next/website/src/content/docs/session-state.mdx1
-rw-r--r--src/agent_resume.rs38
-rw-r--r--src/api/schema/integrations.rs1
-rw-r--r--src/app/actions.rs39
-rw-r--r--src/cli/integration.rs9
-rw-r--r--src/config/model.rs5
-rw-r--r--src/config/sound.rs3
-rw-r--r--src/detect/manifest.rs1
-rw-r--r--src/detect/manifest/tests.rs60
-rw-r--r--src/detect/manifests/devin.toml86
-rw-r--r--src/detect/mod.rs12
-rw-r--r--src/integration/assets/devin/herdr-agent-state.sh173
-rw-r--r--src/integration/mod.rs436
-rw-r--r--src/main.rs4
-rw-r--r--src/terminal/state.rs48
-rw-r--r--tests/cli_wrapper.rs140
22 files changed, 1056 insertions, 45 deletions
diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md
index 8f1c94da..927e8d63 100644
--- a/docs/next/CHANGELOG.md
+++ b/docs/next/CHANGELOG.md
@@ -12,6 +12,10 @@
- Added `herdr plugin config-dir <id>` and automatic plugin config/state
directory creation so plugin setup docs can point users at a stable config
path.
+- Added Devin CLI automatic detection plus `herdr integration install devin`
+ hooks that report session ids for restore with `devin --resume <id>`.
+ Devin state remains screen-detected because Devin hooks do not cover every
+ permission cancellation and user interrupt transition.
- Added supporting plugin host APIs for `pane.current`, `pane.process_info`,
`client.window_title.set/clear`, `layout.export/apply`, plugin pane placement,
plugin invocation context/env injection, and plugin pane ownership across
diff --git a/docs/next/README.md b/docs/next/README.md
index cf0d6c0c..9645a01b 100644
--- a/docs/next/README.md
+++ b/docs/next/README.md
@@ -153,7 +153,7 @@ states:
- πŸ”΅ **done** β€” work finished, you have not looked at it yet
- 🟒 **idle** β€” done and seen
-detection works by reading foreground process and terminal output. zero config, no hooks required. official claude code, codex, github copilot cli, droid, kimi code cli, qodercli, and cursor agent cli integrations provide session restore identity; pi, omp, opencode, kilo code cli, hermes, and custom socket integrations can report their own state.
+detection works by reading foreground process and terminal output. zero config, no hooks required. official claude code, codex, github copilot cli, devin, droid, kimi code cli, qodercli, and cursor agent cli integrations provide session restore identity; pi, omp, kimi code cli, opencode, kilo code cli, hermes, and custom socket integrations can report their own state.
## lives in your terminal
@@ -187,6 +187,7 @@ automatic detection works out of the box. process name matching plus terminal ou
| [grok cli](https://x.ai/grok) | βœ“ | βœ“ | βœ“ |
| [hermes agent](https://github.com/NousResearch/hermes-agent) | βœ“ | βœ“ | βœ“ |
| [kilo code cli](https://kilo.ai/) | βœ“ | βœ“ | βœ“ |
+| [devin cli](https://docs.devin.ai/cli) | βœ“ | βœ“ | βœ“ |
| cursor agent | βœ“ | βœ“ | βœ“ |
| antigravity cli | βœ“ | βœ“ | βœ“ |
| kimi code cli | βœ“ | βœ“ | βœ“ |
@@ -200,7 +201,7 @@ for agents outside the built-in list, herdr still works as a terminal multiplexe
### direct integrations
-official integrations have two roles. claude code, codex, github copilot cli, droid, kimi code cli, qodercli, and cursor agent cli report session identity for native restore, while their state still comes from screen detection. pi, opencode, kilo code cli, and hermes report both semantic state and session identity. omp reports semantic state without native session restore. install with:
+official integrations have two roles. claude code, codex, github copilot cli, devin, droid, qodercli, and cursor agent cli report session identity for native restore, while their state still comes from screen detection. pi, kimi code cli, opencode, kilo code cli, and hermes report both semantic state and session identity. omp reports semantic state without native session restore. install with:
```bash
herdr integration install pi
@@ -208,6 +209,7 @@ herdr integration install omp
herdr integration install claude
herdr integration install codex
herdr integration install copilot
+herdr integration install devin
herdr integration install droid
herdr integration install kimi
herdr integration install opencode
diff --git a/docs/next/website/src/content/docs/agents.mdx b/docs/next/website/src/content/docs/agents.mdx
index 783ad68a..3215f64f 100644
--- a/docs/next/website/src/content/docs/agents.mdx
+++ b/docs/next/website/src/content/docs/agents.mdx
@@ -14,6 +14,7 @@ Automatic detection works out of the box for common coding agents. The important
| Pi | lifecycle hooks when installed; otherwise screen manifest | state and session |
| OMP | lifecycle hooks when installed | state |
| GitHub Copilot CLI | screen manifest | session |
+| Devin CLI | screen manifest | session |
| Kimi Code CLI | lifecycle hooks when installed; otherwise screen manifest | state and session |
| Hermes Agent | lifecycle hooks when installed; otherwise screen manifest | state and session |
| Qoder CLI | screen manifest | session |
diff --git a/docs/next/website/src/content/docs/cli-reference.mdx b/docs/next/website/src/content/docs/cli-reference.mdx
index 35255393..f1e0d834 100644
--- a/docs/next/website/src/content/docs/cli-reference.mdx
+++ b/docs/next/website/src/content/docs/cli-reference.mdx
@@ -246,6 +246,7 @@ herdr integration install omp
herdr integration install claude
herdr integration install codex
herdr integration install copilot
+herdr integration install devin
herdr integration install droid
herdr integration install kimi
herdr integration install opencode
@@ -258,6 +259,7 @@ herdr integration uninstall omp
herdr integration uninstall claude
herdr integration uninstall codex
herdr integration uninstall copilot
+herdr integration uninstall devin
herdr integration uninstall droid
herdr integration uninstall kimi
herdr integration uninstall opencode
diff --git a/docs/next/website/src/content/docs/configuration.mdx b/docs/next/website/src/content/docs/configuration.mdx
index fa8f089d..fbabb8ba 100644
--- a/docs/next/website/src/content/docs/configuration.mdx
+++ b/docs/next/website/src/content/docs/configuration.mdx
@@ -364,7 +364,7 @@ request_path = "sounds/request.mp3"
`path` sets one sound for all sound notifications. `done_path` and `request_path` override only the finished and needs-input sounds.
-Per-agent sound overrides accept `default`, `on`, or `off`. Droid is muted by default.
+Per-agent sound overrides accept `default`, `on`, or `off`. Use detected agent labels such as `claude`, `codex`, `devin`, or `droid` as keys. Droid is muted by default.
```toml
[ui.sound.agents]
@@ -441,7 +441,7 @@ For how native agent session restore differs from pane screen history and live h
## IME cursor tracking
-When the focused pane hides its cursor and paints its own β€” common in AI-agent TUIs like Claude Code, pi, and codex β€” macOS native input methods stop tracking the candidate window position because the outer terminal stops reporting the cursor.
+When the focused pane hides its cursor and paints its own β€” common in AI-agent TUIs like Claude Code, pi, codex, and Devin β€” macOS native input methods stop tracking the candidate window position because the outer terminal stops reporting the cursor.
Set `reveal_hidden_cursor_for_cjk_ime = true` to expose the focused pane's cursor anchor to the outer terminal regardless of the pane's `?25l` request:
@@ -454,7 +454,7 @@ cjk_ime_cursor_shape = "steady_block"
When enabled, the cursor stays visible at the focused pane's reported position. If the pane reports no cursor position, the anchor falls back to the pane's top-left so a stable IME hint is always available.
-`cjk_ime_agents` is an optional allow-list. When empty, the reveal applies to any focused pane. When non-empty, the reveal only applies if the focused pane's detected agent matches one of the listed names β€” useful to enable the reveal only for AI-agent TUIs that paint their own cursor while leaving plain shells untouched. Accepted names: `pi`, `claude`, `codex`, `gemini`, `cursor`, `agy`, `cline`, `opencode`, `copilot`, `kimi`, `kiro`, `droid`, `amp`, `grok`, `hermes`, `kilo`, `qodercli`, and `qoder`. Unknown names are ignored; if the list contains no valid names, the reveal does not apply.
+`cjk_ime_agents` is an optional allow-list. When empty, the reveal applies to any focused pane. When non-empty, the reveal only applies if the focused pane's detected agent matches one of the listed names β€” useful to enable the reveal only for AI-agent TUIs that paint their own cursor while leaving plain shells untouched. Accepted names: `pi`, `claude`, `codex`, `gemini`, `cursor`, `devin`, `agy`, `cline`, `opencode`, `copilot`, `kimi`, `kiro`, `droid`, `amp`, `grok`, `hermes`, `kilo`, `qodercli`, and `qoder`. Unknown names are ignored; if the list contains no valid names, the reveal does not apply.
`cjk_ime_cursor_shape` controls the DECSCUSR shape rendered for the IME anchor. Accepted values: `block`, `steady_block` (default), `underline`, `steady_underline`, `bar`, `steady_bar`.
diff --git a/docs/next/website/src/content/docs/integrations.mdx b/docs/next/website/src/content/docs/integrations.mdx
index 0407bb4a..797456e6 100644
--- a/docs/next/website/src/content/docs/integrations.mdx
+++ b/docs/next/website/src/content/docs/integrations.mdx
@@ -1,11 +1,11 @@
---
title: Integrations
-description: Install Herdr integrations for Pi, OMP, Claude Code, Codex, GitHub Copilot CLI, Droid, Kimi Code CLI, OpenCode, Kilo Code CLI, Hermes Agent, Qoder CLI, and Cursor Agent CLI.
+description: Install Herdr integrations for Pi, OMP, Claude Code, Codex, GitHub Copilot CLI, Devin CLI, Droid, Kimi Code CLI, OpenCode, Kilo Code CLI, Hermes Agent, Qoder CLI, and Cursor Agent CLI.
---
Herdr detects supported agents automatically. Official integrations can add native session identity for restore, lifecycle state reports, or both.
-Use integrations when you want native agent session restore, direct lifecycle reports from Pi/OMP/Copilot/OpenCode/Kilo/Hermes-style hooks or plugins, or both. See [Agents](/docs/agents/) for the full status authority model.
+Use integrations when you want native agent session restore from Claude Code/Codex/Copilot/Devin-style hooks, direct lifecycle reports from Pi/OMP/Kimi/OpenCode/Kilo/Hermes-style hooks or plugins, or both. See [Agents](/docs/agents/) for the full status authority model.
## Install integrations
@@ -17,6 +17,7 @@ herdr integration install omp
herdr integration install claude
herdr integration install codex
herdr integration install copilot
+herdr integration install devin
herdr integration install droid
herdr integration install kimi
herdr integration install opencode
@@ -34,6 +35,7 @@ herdr integration uninstall omp
herdr integration uninstall claude
herdr integration uninstall codex
herdr integration uninstall copilot
+herdr integration uninstall devin
herdr integration uninstall droid
herdr integration uninstall kimi
herdr integration uninstall opencode
@@ -50,13 +52,13 @@ Herdr uses integrations in two different ways:
| Integration type | Agents | Effect |
| --- | --- | --- |
| Lifecycle authority | Pi, OMP, Kimi Code CLI, OpenCode, Kilo Code CLI, Hermes Agent | When installed and actively reporting for the pane, hook or plugin events author `idle`, `working`, and `blocked`. Herdr does not also use screen manifest fallback for that same lifecycle authority. |
-| Session identity | Claude Code, Codex, GitHub Copilot CLI, Droid, Qoder CLI, Cursor Agent CLI | The integration reports native session references for restore. State still comes from Herdr's screen manifest detection. |
+| Session identity | Claude Code, Codex, GitHub Copilot CLI, Devin CLI, Droid, Qoder CLI, Cursor Agent CLI | The integration reports native session references for restore. State still comes from Herdr's screen manifest detection. |
Custom socket integrations can also report state when they define state that is not visible in the native terminal UI.
-Some integrations report native agent session references. Herdr uses official session references to resume Claude Code, Codex, Droid, Kimi Code CLI, Qoder CLI, Cursor Agent CLI, GitHub Copilot CLI, Pi, Hermes Agent, OpenCode, and Kilo Code CLI panes after a Herdr server restart unless `[session] resume_agents_on_restore = false` disables it.
+Some integrations report native agent session references. Herdr uses official session references to resume Claude Code, Codex, Devin CLI, Droid, Kimi Code CLI, Qoder CLI, Cursor Agent CLI, GitHub Copilot CLI, Pi, Hermes Agent, OpenCode, and Kilo Code CLI panes after a Herdr server restart unless `[session] resume_agents_on_restore = false` disables it.
-Native session restore requires current Herdr integrations: Pi integration version `2`, Claude Code version `6`, Codex version `5`, GitHub Copilot CLI version `2`, Droid version `2`, Kimi Code CLI version `3`, Qoder CLI version `2`, Cursor Agent CLI version `1`, OpenCode version `5`, Kilo Code CLI version `1`, or Hermes Agent version `2`. OMP integration version `2` reports agent state only. Check installed versions with `herdr integration status`.
+Native session restore requires current Herdr integrations: Pi integration version `2`, Claude Code version `6`, Codex version `5`, GitHub Copilot CLI version `2`, Devin CLI version `1`, Droid version `2`, Kimi Code CLI version `3`, Qoder CLI version `2`, Cursor Agent CLI version `1`, OpenCode version `5`, Kilo Code CLI version `1`, or Hermes Agent version `2`. OMP integration version `2` reports agent state only. Check installed versions with `herdr integration status`.
## Pi
@@ -130,6 +132,20 @@ Herdr uses `~/.copilot` by default, or `COPILOT_HOME` when set. The Copilot conf
After Copilot emits a session-bearing event, Herdr can use the reported session id to resume the pane with `copilot --resume=<id>`.
+## Devin CLI
+
+Install the Devin CLI hook:
+
+```bash
+herdr integration install devin
+```
+
+The hook reports native session identity from Devin session, prompt, tool-use, permission, and stop events. Devin state still comes from Herdr's screen manifest and OSC detection because Devin hooks do not emit a reliable state transition after every permission cancellation or user interrupt.
+
+Herdr uses `~/.config/devin` by default, or `$XDG_CONFIG_HOME/devin` when `XDG_CONFIG_HOME` is set. The Devin config directory must already exist. Install writes `herdr-agent-state.sh` and updates `config.json` with Herdr hook entries. The hook refreshes the session reference while Devin runs. Uninstall removes Herdr entries from `config.json` and deletes the hook script.
+
+Herdr resumes stored Devin sessions with `devin --resume <id>`. Native screen manifest detection remains the state authority whether or not the hook is installed.
+
## Kimi Code CLI
Install the Kimi Code CLI hook:
diff --git a/docs/next/website/src/content/docs/session-state.mdx b/docs/next/website/src/content/docs/session-state.mdx
index 8edb82d5..6ff70270 100644
--- a/docs/next/website/src/content/docs/session-state.mdx
+++ b/docs/next/website/src/content/docs/session-state.mdx
@@ -71,6 +71,7 @@ Native session restore requires these Herdr integration versions or newer:
| Codex | `5` | `codex resume <id>` |
| Cursor Agent CLI | `1` | `cursor-agent --resume <id>` |
| GitHub Copilot CLI | `2` | `copilot --resume=<id>` |
+| Devin CLI | `1` | `devin --resume <id>` |
| Droid | `2` | `droid --resume <id>` |
| Kimi Code CLI | `3` | `kimi --session <id>` |
| Qoder CLI | `2` | `qodercli --resume <id>` |
diff --git a/src/agent_resume.rs b/src/agent_resume.rs
index cea4a641..4a97f207 100644
--- a/src/agent_resume.rs
+++ b/src/agent_resume.rs
@@ -82,6 +82,7 @@ pub fn is_reserved_native_state_source(source: &str, agent: &str) -> bool {
("herdr:claude", "claude")
| ("herdr:codex", "codex")
| ("herdr:copilot", "copilot")
+ | ("herdr:devin", "devin")
| ("herdr:droid", "droid")
| ("herdr:qodercli", "qodercli")
| ("herdr:cursor", "cursor")
@@ -128,6 +129,9 @@ pub fn plan(source: &str, agent: &str, session_ref: &AgentSessionRef) -> Option<
("herdr:copilot", "copilot", AgentSessionRefKind::Id) => {
vec!["copilot".into(), format!("--resume={}", session_ref.value)]
}
+ ("herdr:devin", "devin", AgentSessionRefKind::Id) => {
+ vec!["devin".into(), "--resume".into(), session_ref.value.clone()]
+ }
("herdr:droid", "droid", AgentSessionRefKind::Id) => {
vec!["droid".into(), "--resume".into(), session_ref.value.clone()]
}
@@ -191,6 +195,7 @@ fn is_official_agent_source(source: &str, agent: &str) -> bool {
("herdr:claude", "claude")
| ("herdr:codex", "codex")
| ("herdr:copilot", "copilot")
+ | ("herdr:devin", "devin")
| ("herdr:droid", "droid")
| ("herdr:kimi", "kimi")
| ("herdr:pi", "pi")
@@ -229,6 +234,7 @@ mod tests {
fn native_state_reservation_excludes_full_lifecycle_sources() {
assert!(is_reserved_native_state_source("herdr:claude", "claude"));
assert!(is_reserved_native_state_source("herdr:codex", "codex"));
+ assert!(is_reserved_native_state_source("herdr:devin", "devin"));
assert!(!is_reserved_native_state_source("herdr:kimi", "kimi"));
assert!(!is_reserved_native_state_source(
"herdr:opencode",
@@ -271,6 +277,16 @@ mod tests {
);
assert_eq!(
plan(
+ "herdr:devin",
+ "devin",
+ &AgentSessionRef::id("devin-session").unwrap()
+ )
+ .unwrap()
+ .argv,
+ vec!["devin", "--resume", "devin-session"]
+ );
+ assert_eq!(
+ plan(
"herdr:droid",
"droid",
&AgentSessionRef::id("droid-session").unwrap()
@@ -404,6 +420,11 @@ mod tests {
);
let session_ref =
+ session_ref_from_report("herdr:devin", "devin", Some("devin-id".into()), None).unwrap();
+ assert_eq!(session_ref.kind, AgentSessionRefKind::Id);
+ assert_eq!(session_ref.value, "devin-id");
+
+ let session_ref =
session_ref_from_report("herdr:droid", "droid", Some("droid-id".into()), None).unwrap();
assert_eq!(session_ref.kind, AgentSessionRefKind::Id);
assert_eq!(session_ref.value, "droid-id");
@@ -474,6 +495,9 @@ mod tests {
)
.unwrap();
assert_eq!(copilot_plan.argv, vec!["copilot", "--resume=abc; rm -rf /"]);
+
+ let devin_plan = plan("herdr:devin", "devin", &AgentSessionRef::id(id).unwrap()).unwrap();
+ assert_eq!(devin_plan.argv, vec!["devin", "--resume", id]);
}
#[test]
@@ -482,6 +506,7 @@ mod tests {
let opencode_session = absolute_test_path("opencode-session");
let kilo_session = absolute_test_path("kilo-session");
let copilot_session = absolute_test_path("copilot-session");
+ let devin_session = absolute_test_path("devin-session");
assert!(plan(
"herdr:hermes",
"hermes",
@@ -506,6 +531,12 @@ mod tests {
&AgentSessionRef::path(&copilot_session).unwrap()
)
.is_none());
+ assert!(plan(
+ "herdr:devin",
+ "devin",
+ &AgentSessionRef::path(&devin_session).unwrap()
+ )
+ .is_none());
assert!(session_ref_from_snapshot(
"herdr:hermes",
"hermes",
@@ -534,5 +565,12 @@ mod tests {
"copilot-session"
)
.is_some());
+ assert!(session_ref_from_snapshot(
+ "herdr:devin",
+ "devin",
+ AgentSessionRefKind::Id,
+ "devin-session"
+ )
+ .is_some());
}
}
diff --git a/src/api/schema/integrations.rs b/src/api/schema/integrations.rs
index 49b27f96..bd4988f1 100644
--- a/src/api/schema/integrations.rs
+++ b/src/api/schema/integrations.rs
@@ -18,6 +18,7 @@ pub enum IntegrationTarget {
Claude,
Codex,
Copilot,
+ Devin,
Droid,
Kimi,
Opencode,
diff --git a/src/app/actions.rs b/src/app/actions.rs
index b033b372..9df07af5 100644
--- a/src/app/actions.rs
+++ b/src/app/actions.rs
@@ -2767,7 +2767,7 @@ impl AppState {
}),
}
};
- let toast = (!is_active_tab).then(&build_toast);
+ let toast = (!is_active_tab).then(build_toast);
let client_notification = (!suppress_active_tab_notifications).then(build_toast);
if toast.is_none() && client_notification.is_none() && sound.is_none() {
@@ -4533,6 +4533,43 @@ mod tests {
}
#[test]
+ fn devin_state_report_refreshes_session_without_overriding_screen_state() {
+ let mut state = app_with_workspaces(&["active"]);
+ let pane_id = *state.workspaces[0].panes.keys().next().unwrap();
+ let terminal_id = state.workspaces[0]
+ .panes
+ .get(&pane_id)
+ .unwrap()
+ .attached_terminal_id
+ .clone();
+
+ state.handle_app_event(AppEvent::StateChanged {
+ pane_id,
+ agent: Some(Agent::Devin),
+ state: AgentState::Idle,
+ visible_blocker: false,
+ visible_working: false,
+ process_exited: false,
+ observed_at: std::time::Instant::now(),
+ });
+ state.handle_app_event(AppEvent::HookStateReported {
+ pane_id,
+ source: "herdr:devin".into(),
+ agent_label: "devin".into(),
+ state: AgentState::Working,
+ message: None,
+ custom_status: None,
+ seq: Some(1),
+ session_ref: crate::agent_resume::AgentSessionRef::id("devin-session"),
+ });
+
+ let terminal = state.terminals.get(&terminal_id).unwrap();
+ assert_eq!(terminal.state, AgentState::Idle);
+ assert!(terminal.hook_authority.is_none());
+ assert!(terminal.persisted_agent_session.is_some());
+ }
+
+ #[test]
fn hidden_custom_session_ref_only_update_marks_session_dirty_without_visible_update() {
let mut state = app_with_workspaces(&["active"]);
let pane_id = *state.workspaces[0].panes.keys().next().unwrap();
diff --git a/src/cli/integration.rs b/src/cli/integration.rs
index e46e04b9..8dc053f1 100644
--- a/src/cli/integration.rs
+++ b/src/cli/integration.rs
@@ -103,13 +103,13 @@ fn parse_integration_target(
) -> std::io::Result<Option<IntegrationTarget>> {
let Some(target) = args.first().map(|arg| arg.as_str()) else {
eprintln!(
- "usage: herdr integration {action} <pi|omp|claude|codex|copilot|droid|kimi|opencode|kilo|hermes|qodercli|cursor>"
+ "usage: herdr integration {action} <pi|omp|claude|codex|copilot|devin|droid|kimi|opencode|kilo|hermes|qodercli|cursor>"
);
return Ok(None);
};
if args.len() != 1 {
eprintln!(
- "usage: herdr integration {action} <pi|omp|claude|codex|copilot|droid|kimi|opencode|kilo|hermes|qodercli|cursor>"
+ "usage: herdr integration {action} <pi|omp|claude|codex|copilot|devin|droid|kimi|opencode|kilo|hermes|qodercli|cursor>"
);
return Ok(None);
}
@@ -120,6 +120,7 @@ fn parse_integration_target(
"claude" => IntegrationTarget::Claude,
"codex" => IntegrationTarget::Codex,
"copilot" => IntegrationTarget::Copilot,
+ "devin" => IntegrationTarget::Devin,
"droid" => IntegrationTarget::Droid,
"kimi" => IntegrationTarget::Kimi,
"opencode" => IntegrationTarget::Opencode,
@@ -130,7 +131,7 @@ fn parse_integration_target(
_ => {
eprintln!("unknown integration target: {target}");
eprintln!(
- "currently supported: pi, omp, claude, codex, copilot, droid, kimi, opencode, kilo, hermes, qodercli, cursor"
+ "currently supported: pi, omp, claude, codex, copilot, devin, droid, kimi, opencode, kilo, hermes, qodercli, cursor"
);
return Ok(None);
}
@@ -146,6 +147,7 @@ fn print_integration_help() {
eprintln!(" herdr integration install claude");
eprintln!(" herdr integration install codex");
eprintln!(" herdr integration install copilot");
+ eprintln!(" herdr integration install devin");
eprintln!(" herdr integration install droid");
eprintln!(" herdr integration install kimi");
eprintln!(" herdr integration install opencode");
@@ -158,6 +160,7 @@ fn print_integration_help() {
eprintln!(" herdr integration uninstall claude");
eprintln!(" herdr integration uninstall codex");
eprintln!(" herdr integration uninstall copilot");
+ eprintln!(" herdr integration uninstall devin");
eprintln!(" herdr integration uninstall droid");
eprintln!(" herdr integration uninstall kimi");
eprintln!(" herdr integration uninstall opencode");
diff --git a/src/config/model.rs b/src/config/model.rs
index 528a940c..95915590 100644
--- a/src/config/model.rs
+++ b/src/config/model.rs
@@ -527,8 +527,9 @@ pub struct ExperimentalConfig {
/// detected agent matches one of these names (case-insensitive). Empty
/// list means apply to any focused pane. Unknown agent names are ignored;
/// if the list contains no valid names, the reveal does not apply.
- /// Accepted names: pi, claude, codex, gemini, cursor, cline, opencode,
- /// copilot, kimi, kiro, droid, amp, grok, hermes, kilo, qodercli, qoder.
+ /// Accepted names: pi, claude, codex, gemini, cursor, devin, cline,
+ /// opencode, copilot, kimi, kiro, droid, amp, grok, hermes, kilo,
+ /// qodercli, qoder.
/// Default: empty.
pub cjk_ime_agents: Vec<String>,
/// Cursor shape rendered for the IME anchor when
diff --git a/src/config/sound.rs b/src/config/sound.rs
index 50b2355b..25376f2a 100644
--- a/src/config/sound.rs
+++ b/src/config/sound.rs
@@ -30,6 +30,7 @@ pub struct AgentSoundOverrides {
pub codex: AgentSoundSetting,
pub gemini: AgentSoundSetting,
pub cursor: AgentSoundSetting,
+ pub devin: AgentSoundSetting,
pub agy: AgentSoundSetting,
pub cline: AgentSoundSetting,
pub open_code: AgentSoundSetting,
@@ -122,6 +123,7 @@ impl AgentSoundOverrides {
Some(Agent::Codex) => self.codex,
Some(Agent::Gemini) => self.gemini,
Some(Agent::Cursor) => self.cursor,
+ Some(Agent::Devin) => self.devin,
Some(Agent::Antigravity) => self.agy,
Some(Agent::Cline) => self.cline,
Some(Agent::OpenCode) => self.open_code,
@@ -159,6 +161,7 @@ impl Default for AgentSoundOverrides {
codex: AgentSoundSetting::Default,
gemini: AgentSoundSetting::Default,
cursor: AgentSoundSetting::Default,
+ devin: AgentSoundSetting::Default,
agy: AgentSoundSetting::Default,
cline: AgentSoundSetting::Default,
open_code: AgentSoundSetting::Default,
diff --git a/src/detect/manifest.rs b/src/detect/manifest.rs
index 06ed4df6..a6da1277 100644
--- a/src/detect/manifest.rs
+++ b/src/detect/manifest.rs
@@ -243,6 +243,7 @@ const BUNDLED_MANIFESTS: &[(&str, &str)] = &[
("cline", include_str!("manifests/cline.toml")),
("codex", include_str!("manifests/codex.toml")),
("cursor", include_str!("manifests/cursor.toml")),
+ ("devin", include_str!("manifests/devin.toml")),
("droid", include_str!("manifests/droid.toml")),
("gemini", include_str!("manifests/gemini.toml")),
("grok", include_str!("manifests/grok.toml")),
diff --git a/src/detect/manifest/tests.rs b/src/detect/manifest/tests.rs
index 8811777b..2570aa03 100644
--- a/src/detect/manifest/tests.rs
+++ b/src/detect/manifest/tests.rs
@@ -297,6 +297,7 @@ fn all_bundled_manifests_parse_and_validate() {
Agent::Codex,
Agent::Gemini,
Agent::Cursor,
+ Agent::Devin,
Agent::Antigravity,
Agent::Cline,
Agent::OpenCode,
@@ -321,6 +322,65 @@ fn all_bundled_manifests_parse_and_validate() {
}
#[test]
+fn devin_manifest_detects_idle_working_and_blocked_states() {
+ let idle = explain(
+ Agent::Devin,
+ "─────────────────────────────────────────────────────\n❭ Ask Devin to build features, fix bugs, or work on\n your code\n─────────────────────────────────────────────────────\nSWE-1.6 Context: 16k / 200k tokens (7%)",
+ );
+ assert_eq!(idle.state, AgentState::Idle);
+ assert!(idle.visible_idle);
+
+ let live_footer_idle = explain(
+ Agent::Devin,
+ "Done.\n\n────────────────────────────────────────────────── (bypass permissions on) ─\n❭\n────────────────────────────────────────────────────────────────────────────\nClaude Opus 4.6 Thinking Context: 38k / 200k tokens (18%)",
+ );
+ assert_eq!(live_footer_idle.state, AgentState::Idle);
+ assert_eq!(
+ live_footer_idle
+ .matched_rule
+ .as_ref()
+ .map(|rule| rule.id.as_str()),
+ Some("live_prompt_footer")
+ );
+ assert!(live_footer_idle.visible_idle);
+
+ let welcome_footer_idle = explain(
+ Agent::Devin,
+ "⠀⠀⠀⠀⠀⣴⣾⣢⑄⠀⠀⠀⠀\nβ €β£΄β£Ύβ£Άβ‘Ύβ ›β Ώβ Ÿβ ƒβ£΄β£Ύβ£Άβ‘„ Devin CLI\nβ €β ›β Ώβ Ÿβ ƒβ£΄β£Ύβ£Άβ‘Ύβ ›β Ώβ Ÿβ ƒ v2026.5.26-8\n⠀⣀⣢⣦⑄⠻Ⓙ⠿Ⓑ⣀⣢⣦⑄\n⠀⠻Ⓙ⠿Ⓑ⣀⣢⣦⑄⠻Ⓙ⠿⠃ Hybrid\nβ €β €β €β €β €β »β’Ώβ Ώβ ƒβ €β €β €β €\n\n───────────────────────────\n❭ Ask Devin to build\n features, fix bugs, or\n work on your code\n───────────────────────────\nClaude Opus Looking for\n4.6 Thinkingplan mode? /\n plan",
+ );
+ assert_eq!(welcome_footer_idle.state, AgentState::Idle);
+ assert_eq!(
+ welcome_footer_idle
+ .matched_rule
+ .as_ref()
+ .map(|rule| rule.id.as_str()),
+ Some("welcome_prompt_footer")
+ );
+ assert!(welcome_footer_idle.visible_idle);
+
+ let working = explain(
+ Agent::Devin,
+ "β—” Reading shell 91b655\n β”‚ Timeout: 35s\n\n⠀⑆ Running tools Β· 27s (esc to interrupt)\n─────────────────────────────────────────────────────\n❭ Guide Devin while it works",
+ );
+ assert_eq!(working.state, AgentState::Working);
+ assert!(working.visible_working);
+
+ let trust_prompt = explain(
+ Agent::Devin,
+ "Do you trust the authors of this directory?\nFor security, devin should not be run in directories\nwith untrusted content.\n❭ 1 Yes, trust /private/tmp/devin-hook-probe\n· 2 No, exit",
+ );
+ assert_eq!(trust_prompt.state, AgentState::Blocked);
+ assert!(trust_prompt.visible_blocker);
+
+ let permission_prompt = explain(
+ Agent::Devin,
+ "⏺ Running command\n β”” $ sleep 30\n\n❭ 1 Yes (Approve once)\nΒ· 2 Yes, allow `sleep` commands\nΒ· 3 Yes, always allow `sleep` commands\nΒ· 4 No\n↑↓ select Β· ↡ confirm Β· esc cancel",
+ );
+ assert_eq!(permission_prompt.state, AgentState::Blocked);
+ assert!(permission_prompt.visible_blocker);
+}
+
+#[test]
fn manifest_validation_rejects_unknown_fields_empty_rules_invalid_regions_and_regexes() {
assert!(parse_manifest(
r#"
diff --git a/src/detect/manifests/devin.toml b/src/detect/manifests/devin.toml
new file mode 100644
index 00000000..c9564f7c
--- /dev/null
+++ b/src/detect/manifests/devin.toml
@@ -0,0 +1,86 @@
+id = "devin"
+version = "2026.06.15.1"
+min_engine_version = 1
+updated_at = "2026-06-15T00:00:00Z"
+aliases = ["devin-cli", "devin cli"]
+
+[[rules]]
+id = "workspace_trust_prompt"
+state = "blocked"
+priority = 300
+region = "bottom_non_empty_lines(8)"
+visible_blocker = true
+contains = [
+ "do you trust the authors of this directory?",
+ "with untrusted content.",
+ "yes, trust ",
+]
+
+[[rules]]
+id = "permission_prompt"
+state = "blocked"
+priority = 290
+region = "bottom_non_empty_lines(8)"
+visible_blocker = true
+contains = ["approve once", "select", "confirm", "esc cancel"]
+
+[[rules]]
+id = "running_tools_footer"
+state = "working"
+priority = 200
+region = "bottom_non_empty_lines(8)"
+visible_working = true
+contains = ["running tools", "esc to interrupt"]
+not = [
+ { contains = ["approve once", "esc cancel"] },
+]
+
+[[rules]]
+id = "guide_while_working"
+state = "working"
+priority = 190
+region = "bottom_non_empty_lines(6)"
+visible_working = true
+contains = ["guide devin while it works"]
+not = [
+ { contains = ["approve once", "esc cancel"] },
+]
+
+[[rules]]
+id = "tool_reading_timeout"
+state = "working"
+priority = 180
+region = "bottom_non_empty_lines(8)"
+visible_working = true
+contains = ["reading shell ", "timeout:"]
+not = [
+ { contains = ["approve once", "esc cancel"] },
+]
+
+[[rules]]
+id = "welcome_prompt_footer"
+state = "idle"
+priority = 120
+region = "bottom_non_empty_lines(8)"
+visible_idle = true
+contains = ["ask devin to build", "features, fix bugs", "your code"]
+line_regex = ['^\s*❭ Ask Devin to build']
+not = [
+ { contains = ["approve once", "esc cancel"] },
+ { contains = ["running tools", "esc to interrupt"] },
+ { contains = ["guide devin while it works"] },
+]
+
+[[rules]]
+id = "live_prompt_footer"
+state = "idle"
+priority = 100
+region = "bottom_non_empty_lines(6)"
+visible_idle = true
+contains = ["context:"]
+line_regex = ['^\s*❭']
+not = [
+ { contains = ["approve once", "esc cancel"] },
+ { contains = ["running tools", "esc to interrupt"] },
+ { contains = ["guide devin while it works"] },
+]
diff --git a/src/detect/mod.rs b/src/detect/mod.rs
index 6a8a25c5..66443877 100644
--- a/src/detect/mod.rs
+++ b/src/detect/mod.rs
@@ -46,6 +46,7 @@ pub enum Agent {
Codex,
Gemini,
Cursor,
+ Devin,
Antigravity,
Cline,
OpenCode,
@@ -61,12 +62,13 @@ pub enum Agent {
}
impl Agent {
- pub const ALL: [Self; 17] = [
+ pub const ALL: [Self; 18] = [
Self::Pi,
Self::Claude,
Self::Codex,
Self::Gemini,
Self::Cursor,
+ Self::Devin,
Self::Antigravity,
Self::Cline,
Self::OpenCode,
@@ -89,6 +91,7 @@ pub fn agent_label(agent: Agent) -> &'static str {
Agent::Codex => "codex",
Agent::Gemini => "gemini",
Agent::Cursor => "cursor",
+ Agent::Devin => "devin",
Agent::Antigravity => "agy",
Agent::Cline => "cline",
Agent::OpenCode => "opencode",
@@ -112,6 +115,7 @@ pub fn parse_agent_label(agent: &str) -> Option<Agent> {
"codex" => Some(Agent::Codex),
"gemini" => Some(Agent::Gemini),
"cursor" | "cursor-agent" => Some(Agent::Cursor),
+ "devin" | "devin-cli" | "devin cli" => Some(Agent::Devin),
"agy" | "antigravity" | "antigravity-cli" => Some(Agent::Antigravity),
"cline" => Some(Agent::Cline),
"opencode" | "open-code" => Some(Agent::OpenCode),
@@ -139,6 +143,7 @@ pub fn identify_agent(process_name: &str) -> Option<Agent> {
"codex" => Some(Agent::Codex),
"gemini" => Some(Agent::Gemini),
"cursor" | "cursor-agent" => Some(Agent::Cursor),
+ "devin" | "devin-cli" | "devin cli" => Some(Agent::Devin),
"agy" | "antigravity" | "antigravity-cli" => Some(Agent::Antigravity),
"cline" => Some(Agent::Cline),
"opencode" | "open-code" => Some(Agent::OpenCode),
@@ -582,6 +587,8 @@ mod tests {
assert_eq!(identify_agent("gemini"), Some(Agent::Gemini));
assert_eq!(identify_agent("cursor"), Some(Agent::Cursor));
assert_eq!(identify_agent("cursor-agent"), Some(Agent::Cursor));
+ assert_eq!(identify_agent("devin"), Some(Agent::Devin));
+ assert_eq!(identify_agent("devin-cli"), Some(Agent::Devin));
assert_eq!(identify_agent("agy"), Some(Agent::Antigravity));
assert_eq!(identify_agent("antigravity-cli"), Some(Agent::Antigravity));
assert_eq!(identify_agent("cline"), Some(Agent::Cline));
@@ -606,6 +613,7 @@ mod tests {
assert_eq!(parse_agent_label("pi"), Some(Agent::Pi));
assert_eq!(parse_agent_label("claude"), Some(Agent::Claude));
assert_eq!(parse_agent_label("cursor-agent"), Some(Agent::Cursor));
+ assert_eq!(parse_agent_label("devin-cli"), Some(Agent::Devin));
assert_eq!(parse_agent_label("agy"), Some(Agent::Antigravity));
assert_eq!(parse_agent_label("antigravity"), Some(Agent::Antigravity));
assert_eq!(parse_agent_label("opencode.exe"), Some(Agent::OpenCode));
@@ -627,6 +635,7 @@ mod tests {
assert_eq!(agent_label(Agent::Pi), "pi");
assert_eq!(agent_label(Agent::GithubCopilot), "copilot");
assert_eq!(agent_label(Agent::OpenCode), "opencode");
+ assert_eq!(agent_label(Agent::Devin), "devin");
assert_eq!(agent_label(Agent::Antigravity), "agy");
assert_eq!(agent_label(Agent::Kiro), "kiro");
assert_eq!(agent_label(Agent::Grok), "grok");
@@ -647,6 +656,7 @@ mod tests {
assert_eq!(identify_agent("Pi"), Some(Agent::Pi));
assert_eq!(identify_agent("CLAUDE"), Some(Agent::Claude));
assert_eq!(identify_agent("Codex"), Some(Agent::Codex));
+ assert_eq!(identify_agent("Devin"), Some(Agent::Devin));
}
#[test]
diff --git a/src/integration/assets/devin/herdr-agent-state.sh b/src/integration/assets/devin/herdr-agent-state.sh
new file mode 100644
index 00000000..83f8c190
--- /dev/null
+++ b/src/integration/assets/devin/herdr-agent-state.sh
@@ -0,0 +1,173 @@
+#!/bin/sh
+# installed by herdr
+# managed by herdr; reinstalling or updating the integration overwrites this file.
+# add custom hooks beside this file instead of editing it.
+# HERDR_INTEGRATION_ID=devin
+# HERDR_INTEGRATION_VERSION=1
+
+set -eu
+
+action="${1:-}"
+hook_input_file="$(mktemp "${TMPDIR:-/tmp}/herdr-devin-hook.XXXXXX")" || exit 0
+trap 'rm -f "$hook_input_file"' EXIT HUP INT TERM
+cat >"$hook_input_file" 2>/dev/null || true
+
+case "$action" in
+ session) ;;
+ *) 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_HOOK_INPUT_FILE="$hook_input_file" python3 - <<'PY'
+import json
+import os
+import random
+import socket
+import subprocess
+import time
+
+SOURCE = "herdr:devin"
+AGENT = "devin"
+
+
+def load_hook_input(path: str | None) -> dict:
+ if not path:
+ return {}
+ try:
+ with open(path, encoding="utf-8") as handle:
+ content = handle.read()
+ if not content.strip():
+ return {}
+ parsed = json.loads(content)
+ return parsed if isinstance(parsed, dict) else {}
+ except Exception:
+ return {}
+
+
+def load_session_list(project_dir: str | None):
+ injected = os.environ.get("HERDR_DEVIN_LIST_JSON")
+ if injected is not None:
+ try:
+ parsed = json.loads(injected)
+ return parsed if isinstance(parsed, list) else []
+ except Exception:
+ return []
+
+ cmd = ["devin", "list", "--format", "json"]
+ try:
+ result = subprocess.run(
+ cmd,
+ cwd=project_dir or None,
+ capture_output=True,
+ text=True,
+ timeout=2,
+ check=False,
+ )
+ except Exception:
+ return []
+
+ if result.returncode != 0:
+ return []
+
+ try:
+ parsed = json.loads(result.stdout)
+ return parsed if isinstance(parsed, list) else []
+ except Exception:
+ return []
+
+
+def normalize_path(path: str | None) -> str | None:
+ if not isinstance(path, str) or not path:
+ return None
+ try:
+ return os.path.realpath(path)
+ except Exception:
+ return path
+
+
+def hook_session_id(hook_input: dict) -> str | None:
+ for key in ("session_id", "sessionId"):
+ value = hook_input.get(key)
+ if isinstance(value, str) and value:
+ return value
+ return None
+
+
+def hook_event_name(hook_input: dict) -> str:
+ value = hook_input.get("hook_event_name")
+ return value if isinstance(value, str) else ""
+
+
+def allow_session_list_fallback(hook_input: dict) -> bool:
+ event = hook_event_name(hook_input)
+ if event == "UserPromptSubmit":
+ return False
+ if event == "SessionStart" and hook_input.get("source") == "startup":
+ return False
+ return True
+
+
+def resolve_session_id(project_dir: str, hook_input: dict) -> str | None:
+ direct = hook_session_id(hook_input)
+ if direct:
+ return direct
+ if not allow_session_list_fallback(hook_input):
+ return None
+
+ entries = load_session_list(project_dir)
+ project_dir = normalize_path(project_dir)
+ for entry in entries:
+ if not isinstance(entry, dict):
+ continue
+ session_id = entry.get("id")
+ if not isinstance(session_id, str) or not session_id:
+ continue
+ working_directory = normalize_path(entry.get("working_directory"))
+ if working_directory == project_dir:
+ return session_id
+ return None
+
+
+pane_id = os.environ.get("HERDR_PANE_ID")
+socket_path = os.environ.get("HERDR_SOCKET_PATH")
+project_dir = os.environ.get("DEVIN_PROJECT_DIR") or os.getcwd()
+hook_input = load_hook_input(os.environ.get("HERDR_HOOK_INPUT_FILE"))
+
+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}"
+report_seq = time.time_ns()
+
+session_id = resolve_session_id(project_dir, hook_input)
+if not session_id:
+ raise SystemExit(0)
+request = {
+ "id": request_id,
+ "method": "pane.report_agent_session",
+ "params": {
+ "pane_id": pane_id,
+ "source": SOURCE,
+ "agent": AGENT,
+ "agent_session_id": session_id,
+ "seq": report_seq,
+ },
+}
+
+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
diff --git a/src/integration/mod.rs b/src/integration/mod.rs
index ab1e9e2d..234d1b10 100644
--- a/src/integration/mod.rs
+++ b/src/integration/mod.rs
@@ -92,6 +92,25 @@ const COPILOT_REMOVED_LIFECYCLE_HOOK_EVENTS: [&str; 9] = [
"notification",
"sessionStart",
];
+const DEVIN_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh";
+const DEVIN_HOOK_ASSET: &str = include_str!("assets/devin/herdr-agent-state.sh");
+const DEVIN_INTEGRATION_VERSION: u32 = 1;
+const DEVIN_HOOK_EVENTS: [(&str, &str); 6] = [
+ ("SessionStart", "session"),
+ ("UserPromptSubmit", "session"),
+ ("PreToolUse", "session"),
+ ("PostToolUse", "session"),
+ ("PermissionRequest", "session"),
+ ("Stop", "session"),
+];
+const DEVIN_REMOVED_LIFECYCLE_HOOK_EVENTS: [(&str, &str); 6] = [
+ ("UserPromptSubmit", "working"),
+ ("PreToolUse", "working"),
+ ("PostToolUse", "working"),
+ ("PermissionRequest", "blocked"),
+ ("Stop", "idle"),
+ ("SessionEnd", "release"),
+];
const DROID_HOOK_INSTALL_NAME: &str = if cfg!(windows) {
"herdr-agent-state.ps1"
} else {
@@ -186,6 +205,12 @@ pub(crate) struct CopilotInstallPaths {
}
#[derive(Debug)]
+pub(crate) struct DevinInstallPaths {
+ pub hook_path: PathBuf,
+ pub settings_path: PathBuf,
+}
+
+#[derive(Debug)]
pub(crate) struct DroidInstallPaths {
pub hook_path: PathBuf,
pub hooks_path: PathBuf,
@@ -331,6 +356,14 @@ pub(crate) struct CopilotUninstallResult {
}
#[derive(Debug)]
+pub(crate) struct DevinUninstallResult {
+ pub hook_path: PathBuf,
+ pub settings_path: PathBuf,
+ pub removed_hook_file: bool,
+ pub updated_settings: bool,
+}
+
+#[derive(Debug)]
pub(crate) struct DroidUninstallResult {
pub hook_path: PathBuf,
pub hooks_path: PathBuf,
@@ -532,6 +565,19 @@ fn install_target_inner(target: crate::api::schema::IntegrationTarget) -> io::Re
),
]
}
+ crate::api::schema::IntegrationTarget::Devin => {
+ let installed = install_devin()?;
+ vec![
+ format!(
+ "installed devin integration hook to {}",
+ installed.hook_path.display()
+ ),
+ format!(
+ "ensured devin settings at {}",
+ installed.settings_path.display()
+ ),
+ ]
+ }
crate::api::schema::IntegrationTarget::Kimi => {
let installed = install_kimi()?;
vec![
@@ -739,6 +785,33 @@ pub(crate) fn uninstall_target(
}
messages
}
+ crate::api::schema::IntegrationTarget::Devin => {
+ let result = uninstall_devin()?;
+ let mut messages = Vec::new();
+ if result.removed_hook_file {
+ messages.push(format!(
+ "removed devin hook at {}",
+ result.hook_path.display()
+ ));
+ } else {
+ messages.push(format!(
+ "no devin hook found at {}",
+ result.hook_path.display()
+ ));
+ }
+ if result.updated_settings {
+ messages.push(format!(
+ "removed herdr devin hook entries from {}",
+ result.settings_path.display()
+ ));
+ } else {
+ messages.push(format!(
+ "no herdr devin hook entries found in {}",
+ result.settings_path.display()
+ ));
+ }
+ messages
+ }
crate::api::schema::IntegrationTarget::Kimi => {
let result = uninstall_kimi()?;
let mut messages = Vec::new();
@@ -928,6 +1001,7 @@ pub(crate) fn integration_target_label(
crate::api::schema::IntegrationTarget::Claude => "claude",
crate::api::schema::IntegrationTarget::Codex => "codex",
crate::api::schema::IntegrationTarget::Copilot => "copilot",
+ crate::api::schema::IntegrationTarget::Devin => "devin",
crate::api::schema::IntegrationTarget::Droid => "droid",
crate::api::schema::IntegrationTarget::Kimi => "kimi",
crate::api::schema::IntegrationTarget::Opencode => "opencode",
@@ -951,6 +1025,7 @@ fn integration_target_command_names(
crate::api::schema::IntegrationTarget::Claude => &["claude"],
crate::api::schema::IntegrationTarget::Codex => &["codex"],
crate::api::schema::IntegrationTarget::Copilot => &["copilot"],
+ crate::api::schema::IntegrationTarget::Devin => &["devin"],
crate::api::schema::IntegrationTarget::Droid => &["droid"],
crate::api::schema::IntegrationTarget::Kimi => &["kimi"],
crate::api::schema::IntegrationTarget::Opencode => &["opencode"],
@@ -1162,7 +1237,7 @@ fn integration_specs() -> [(
crate::api::schema::IntegrationTarget,
io::Result<PathBuf>,
u32,
-); 12] {
+); 13] {
[
(
crate::api::schema::IntegrationTarget::Pi,
@@ -1190,6 +1265,11 @@ fn integration_specs() -> [(
COPILOT_INTEGRATION_VERSION,
),
(
+ crate::api::schema::IntegrationTarget::Devin,
+ devin_dir().map(|dir| dir.join(DEVIN_HOOK_INSTALL_NAME)),
+ DEVIN_INTEGRATION_VERSION,
+ ),
+ (
crate::api::schema::IntegrationTarget::Droid,
droid_dir().map(|dir| dir.join("hooks").join(DROID_HOOK_INSTALL_NAME)),
DROID_INTEGRATION_VERSION,
@@ -1582,6 +1662,62 @@ pub(crate) fn install_copilot() -> io::Result<CopilotInstallPaths> {
})
}
+pub(crate) fn install_devin() -> io::Result<DevinInstallPaths> {
+ let dir = devin_dir()?;
+ if !dir.is_dir() {
+ return Err(io::Error::other(format!(
+ "devin config directory not found at {}. install devin cli first",
+ dir.display()
+ )));
+ }
+
+ let hook_path = dir.join(DEVIN_HOOK_INSTALL_NAME);
+ fs::write(&hook_path, DEVIN_HOOK_ASSET)?;
+ make_executable(&hook_path)?;
+
+ let settings_path = dir.join("config.json");
+ let mut settings = if settings_path.is_file() {
+ serde_json::from_str::<Value>(&fs::read_to_string(&settings_path)?).map_err(|err| {
+ io::Error::other(format!(
+ "failed to parse {}: {err}",
+ settings_path.display()
+ ))
+ })?
+ } else {
+ json!({})
+ };
+
+ let hooks = ensure_hooks_object(
+ &mut settings,
+ &settings_path,
+ "devin settings",
+ "devin settings hooks",
+ )?;
+ for (event, action) in DEVIN_REMOVED_LIFECYCLE_HOOK_EVENTS {
+ remove_hook_commands(hooks, event, &hook_path, Some(action))?;
+ }
+ for (event, action) in DEVIN_HOOK_EVENTS {
+ remove_hook_commands(hooks, event, &hook_path, Some(action))?;
+ }
+ for (event, action) in DEVIN_HOOK_EVENTS {
+ ensure_command_hook(
+ hooks,
+ event,
+ hook_command(&hook_path, Some(action)),
+ 10,
+ None,
+ )?;
+ }
+ remove_legacy_bash_hook_file(&hook_path)?;
+
+ fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?;
+
+ Ok(DevinInstallPaths {
+ hook_path,
+ settings_path,
+ })
+}
+
pub(crate) fn install_droid() -> io::Result<DroidInstallPaths> {
let dir = droid_dir()?;
if !dir.is_dir() {
@@ -1941,6 +2077,51 @@ pub(crate) fn uninstall_copilot() -> io::Result<CopilotUninstallResult> {
})
}
+pub(crate) fn uninstall_devin() -> io::Result<DevinUninstallResult> {
+ let devin_dir = devin_dir()?;
+ let hook_path = devin_dir.join(DEVIN_HOOK_INSTALL_NAME);
+ let settings_path = devin_dir.join("config.json");
+ let mut updated_settings = false;
+
+ if settings_path.is_file() {
+ let mut settings = serde_json::from_str::<Value>(&fs::read_to_string(&settings_path)?)
+ .map_err(|err| {
+ io::Error::other(format!(
+ "failed to parse {}: {err}",
+ settings_path.display()
+ ))
+ })?;
+
+ if let Some(hooks) = hooks_object_if_present(
+ &mut settings,
+ &settings_path,
+ "devin settings",
+ "devin settings hooks",
+ )? {
+ for (event, action) in DEVIN_REMOVED_LIFECYCLE_HOOK_EVENTS {
+ updated_settings |= remove_hook_commands(hooks, event, &hook_path, Some(action))?;
+ }
+ for (event, action) in DEVIN_HOOK_EVENTS {
+ updated_settings |= remove_hook_commands(hooks, event, &hook_path, Some(action))?;
+ }
+ }
+
+ if updated_settings {
+ fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?;
+ }
+ }
+
+ let removed_hook_file =
+ remove_file_if_exists(&hook_path)? | remove_legacy_bash_hook_file(&hook_path)?;
+
+ Ok(DevinUninstallResult {
+ hook_path,
+ settings_path,
+ removed_hook_file,
+ updated_settings,
+ })
+}
+
pub(crate) fn uninstall_droid() -> io::Result<DroidUninstallResult> {
let droid_dir = droid_dir()?;
let hook_path = droid_dir.join("hooks").join(DROID_HOOK_INSTALL_NAME);
@@ -3201,6 +3382,14 @@ fn copilot_dir() -> io::Result<PathBuf> {
config_dir_from_env_or_home(COPILOT_HOME_ENV_VAR, &[".copilot"])
}
+fn devin_dir() -> io::Result<PathBuf> {
+ if let Some(value) = std::env::var_os("XDG_CONFIG_HOME").filter(|value| !value.is_empty()) {
+ return expand_tilde_path(PathBuf::from(value)).map(|path| path.join("devin"));
+ }
+
+ Ok(home_dir()?.join(".config").join("devin"))
+}
+
fn droid_dir() -> io::Result<PathBuf> {
Ok(home_dir()?.join(".factory"))
}
@@ -3387,6 +3576,7 @@ mod tests {
std::env::remove_var(CODEX_HOME_ENV_VAR);
std::env::remove_var(COPILOT_HOME_ENV_VAR);
std::env::remove_var(KIMI_CODE_HOME_ENV_VAR);
+ std::env::remove_var("XDG_CONFIG_HOME");
std::env::remove_var(QODERCLI_CONFIG_DIR_ENV_VAR);
std::env::remove_var(CURSOR_CONFIG_DIR_ENV_VAR);
}
@@ -3462,6 +3652,7 @@ mod tests {
assert!(!integration_target_supported(IntegrationTarget::Kilo));
assert!(!integration_target_supported(IntegrationTarget::Hermes));
assert!(!integration_target_supported(IntegrationTarget::Cursor));
+ assert!(!integration_target_supported(IntegrationTarget::Devin));
assert!(integration_target_supported(IntegrationTarget::Claude));
assert!(integration_target_supported(IntegrationTarget::Codex));
@@ -3489,6 +3680,7 @@ mod tests {
fs::write(bin.join("kilo.cmd"), "@echo off\r\n").unwrap();
fs::write(bin.join("hermes.exe"), "").unwrap();
fs::write(bin.join("cursor-agent.cmd"), "@echo off\r\n").unwrap();
+ fs::write(bin.join("devin.cmd"), "@echo off\r\n").unwrap();
assert!(!integration_target_available(IntegrationTarget::Pi));
assert!(!integration_target_available(IntegrationTarget::Omp));
@@ -3496,6 +3688,7 @@ mod tests {
assert!(!integration_target_available(IntegrationTarget::Kilo));
assert!(!integration_target_available(IntegrationTarget::Hermes));
assert!(!integration_target_available(IntegrationTarget::Cursor));
+ assert!(!integration_target_available(IntegrationTarget::Devin));
if let Some(path) = original_path {
std::env::set_var("PATH", path);
@@ -4933,6 +5126,241 @@ mod tests {
}
#[test]
+ fn install_devin_writes_hook_and_updates_settings() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let xdg_config = base.join("xdg");
+ let devin_dir = xdg_config.join("devin");
+ fs::create_dir_all(&devin_dir).unwrap();
+ fs::write(
+ devin_dir.join("config.json"),
+ r#"{"theme_mode":"dark","hooks":{}}"#,
+ )
+ .unwrap();
+ std::env::set_var("XDG_CONFIG_HOME", &xdg_config);
+ std::env::set_var("HOME", base.join("home"));
+
+ let installed = install_devin().unwrap();
+ let hook_content = fs::read_to_string(&installed.hook_path).unwrap();
+ let settings: Value =
+ serde_json::from_str(&fs::read_to_string(&installed.settings_path).unwrap()).unwrap();
+
+ assert_eq!(installed.hook_path, devin_dir.join(DEVIN_HOOK_INSTALL_NAME));
+ assert_eq!(installed.settings_path, devin_dir.join("config.json"));
+ assert_eq!(hook_content, DEVIN_HOOK_ASSET);
+ assert_eq!(settings["theme_mode"], "dark");
+ for (event, action) in DEVIN_HOOK_EVENTS {
+ let command = settings["hooks"][event][0]["hooks"][0]["command"]
+ .as_str()
+ .unwrap();
+ assert!(
+ command.contains(DEVIN_HOOK_INSTALL_NAME) && command.ends_with(action),
+ "expected devin {event} hook command to end with {action}, got {command}"
+ );
+ }
+
+ clear_integration_path_env();
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+ }
+
+ #[test]
+ fn install_devin_is_idempotent_for_hook_entries() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let xdg_config = base.join("xdg");
+ let devin_dir = xdg_config.join("devin");
+ fs::create_dir_all(&devin_dir).unwrap();
+ std::env::set_var("XDG_CONFIG_HOME", &xdg_config);
+ std::env::set_var("HOME", base.join("home"));
+
+ install_devin().unwrap();
+ install_devin().unwrap();
+
+ let settings: Value =
+ serde_json::from_str(&fs::read_to_string(devin_dir.join("config.json")).unwrap())
+ .unwrap();
+ for (event, _) in DEVIN_HOOK_EVENTS {
+ assert_eq!(
+ settings["hooks"][event].as_array().unwrap().len(),
+ 1,
+ "expected hooks.{event} to be idempotent"
+ );
+ }
+
+ clear_integration_path_env();
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+ }
+
+ #[test]
+ fn install_devin_removes_legacy_lifecycle_hook_entries() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let xdg_config = base.join("xdg");
+ let devin_dir = xdg_config.join("devin");
+ fs::create_dir_all(&devin_dir).unwrap();
+ std::env::set_var("XDG_CONFIG_HOME", &xdg_config);
+ std::env::set_var("HOME", base.join("home"));
+
+ let hook_path = devin_dir.join(DEVIN_HOOK_INSTALL_NAME);
+ let mut hooks = Map::new();
+ for (event, action) in DEVIN_REMOVED_LIFECYCLE_HOOK_EVENTS {
+ hooks.insert(
+ event.to_string(),
+ json!([
+ {
+ "hooks": [{
+ "type": "command",
+ "command": hook_command(&hook_path, Some(action)),
+ "timeout": 10
+ }]
+ }
+ ]),
+ );
+ }
+ fs::write(
+ devin_dir.join("config.json"),
+ serde_json::to_string_pretty(&json!({ "hooks": hooks })).unwrap(),
+ )
+ .unwrap();
+
+ install_devin().unwrap();
+
+ let settings: Value =
+ serde_json::from_str(&fs::read_to_string(devin_dir.join("config.json")).unwrap())
+ .unwrap();
+ for (event, action) in DEVIN_REMOVED_LIFECYCLE_HOOK_EVENTS {
+ let legacy_command = hook_command(&hook_path, Some(action));
+ let entries = settings["hooks"][event].as_array();
+ assert!(
+ entries.is_none_or(|entries| {
+ entries.iter().all(|entry| {
+ entry
+ .get("hooks")
+ .and_then(Value::as_array)
+ .is_none_or(|hooks| {
+ hooks.iter().all(|hook| {
+ hook.get("command").and_then(Value::as_str)
+ != Some(legacy_command.as_str())
+ })
+ })
+ })
+ }),
+ "expected legacy devin {event} -> {action} hook to be removed"
+ );
+
+ if !DEVIN_HOOK_EVENTS
+ .iter()
+ .any(|(installed_event, _)| installed_event == &event)
+ {
+ continue;
+ }
+
+ let session_command = hook_command(&hook_path, Some("session"));
+ let entries = entries.unwrap();
+ assert!(
+ entries.iter().any(|entry| {
+ entry
+ .get("hooks")
+ .and_then(Value::as_array)
+ .is_some_and(|hooks| {
+ hooks.iter().any(|hook| {
+ hook.get("command").and_then(Value::as_str)
+ == Some(session_command.as_str())
+ })
+ })
+ }),
+ "expected devin {event} session hook to be installed"
+ );
+ }
+
+ clear_integration_path_env();
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+ }
+
+ #[test]
+ fn uninstall_devin_removes_herdr_hooks_and_preserves_others() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let xdg_config = base.join("xdg");
+ let devin_dir = xdg_config.join("devin");
+ fs::create_dir_all(&devin_dir).unwrap();
+ std::env::set_var("XDG_CONFIG_HOME", &xdg_config);
+ std::env::set_var("HOME", base.join("home"));
+
+ install_devin().unwrap();
+
+ let hook_path = devin_dir.join(DEVIN_HOOK_INSTALL_NAME);
+ let mut settings: Value =
+ serde_json::from_str(&fs::read_to_string(devin_dir.join("config.json")).unwrap())
+ .unwrap();
+ settings["hooks"]["UserPromptSubmit"]
+ .as_array_mut()
+ .unwrap()
+ .push(json!({
+ "matcher": "*",
+ "hooks": [{
+ "type": "command",
+ "command": "echo keep",
+ "timeout": 10
+ }]
+ }));
+ fs::write(
+ devin_dir.join("config.json"),
+ serde_json::to_string_pretty(&settings).unwrap(),
+ )
+ .unwrap();
+
+ let result = uninstall_devin().unwrap();
+ let settings: Value =
+ serde_json::from_str(&fs::read_to_string(devin_dir.join("config.json")).unwrap())
+ .unwrap();
+
+ assert!(result.removed_hook_file);
+ assert!(result.updated_settings);
+ assert!(!hook_path.exists());
+ assert_eq!(
+ settings["hooks"]["UserPromptSubmit"]
+ .as_array()
+ .unwrap()
+ .len(),
+ 1
+ );
+ assert_eq!(
+ settings["hooks"]["UserPromptSubmit"][0]["hooks"][0]["command"],
+ "echo keep"
+ );
+ assert!(settings["hooks"].get("SessionStart").is_none());
+ assert!(settings["hooks"].get("PreToolUse").is_none());
+ assert!(settings["hooks"].get("PermissionRequest").is_none());
+ assert!(settings["hooks"].get("Stop").is_none());
+ assert!(settings["hooks"].get("SessionEnd").is_none());
+
+ clear_integration_path_env();
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+ }
+
+ #[test]
+ fn install_devin_errors_when_config_dir_missing() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let xdg_config = base.join("xdg");
+ fs::create_dir_all(&xdg_config).unwrap();
+ std::env::set_var("XDG_CONFIG_HOME", &xdg_config);
+ std::env::set_var("HOME", base.join("home"));
+
+ let err = install_devin().unwrap_err().to_string();
+ assert!(err.contains("devin config directory not found"));
+
+ clear_integration_path_env();
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+ }
+
+ #[test]
fn install_droid_writes_hook_to_settings_and_cleans_legacy_hooks_json() {
let _lock = integration_env_lock();
let base = unique_base();
@@ -5561,6 +5989,12 @@ mod tests {
assert!(COPILOT_HOOK_ASSET.contains("pane.report_agent_session"));
assert!(!COPILOT_HOOK_ASSET.contains("\"state\":"));
assert!(!COPILOT_HOOK_ASSET.contains("pane.release_agent"));
+ assert!(DEVIN_HOOK_ASSET.contains("HERDR_DEVIN_LIST_JSON"));
+ assert!(DEVIN_HOOK_ASSET.contains("\"method\": \"pane.report_agent_session\""));
+ assert!(!DEVIN_HOOK_ASSET.contains("\"method\": \"pane.report_agent\""));
+ assert!(!DEVIN_HOOK_ASSET.contains("\"state\":"));
+ assert!(!DEVIN_HOOK_ASSET.contains("pane.release_agent"));
+ assert!(DEVIN_HOOK_ASSET.contains("agent_session_id"));
assert!(DROID_HOOK_ASSET.contains("agent_session_id"));
assert!(DROID_HOOK_ASSET.contains("pane.report_agent_session"));
assert!(!DROID_HOOK_ASSET.contains("\"state\": action"));
diff --git a/src/main.rs b/src/main.rs
index 99428897..d78dd2ad 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -322,8 +322,8 @@ pane_history = false
# Optional allow-list: only reveal for focused panes whose detected agent
# matches one of these names. Empty means apply to any focused pane.
# If the list contains no valid names, the reveal does not apply.
-# Accepted: pi, claude, codex, gemini, cursor, cline, opencode, copilot,
-# kimi, kiro, droid, amp, grok, hermes, kilo, qodercli, qoder.
+# Accepted: pi, claude, codex, gemini, cursor, devin, cline, opencode,
+# copilot, kimi, kiro, droid, amp, grok, hermes, kilo, qodercli, qoder.
# cjk_ime_agents = []
# Cursor shape rendered when reveal_hidden_cursor_for_cjk_ime is true.
# Values: block, steady_block (default), underline, steady_underline, bar, steady_bar.
diff --git a/src/terminal/state.rs b/src/terminal/state.rs
index 932e0735..9b9b6d43 100644
--- a/src/terminal/state.rs
+++ b/src/terminal/state.rs
@@ -1073,31 +1073,37 @@ mod tests {
#[test]
fn session_only_report_does_not_create_hook_authority() {
- let mut terminal = test_terminal();
- terminal.set_detected_state(Some(Agent::Codex), AgentState::Idle);
+ for (agent, source, label, session_id) in [
+ (Agent::Codex, "herdr:codex", "codex", "codex-session"),
+ (Agent::Devin, "herdr:devin", "devin", "devin-session"),
+ ] {
+ let mut terminal = test_terminal();
+ terminal.set_detected_state(Some(agent), AgentState::Idle);
- let mutation = terminal.set_agent_session_ref(
- "herdr:codex".into(),
- "codex".into(),
- crate::agent_resume::AgentSessionRef::id("codex-session"),
- Some(1),
- );
+ let mutation = terminal.set_agent_session_ref(
+ source.into(),
+ label.into(),
+ crate::agent_resume::AgentSessionRef::id(session_id),
+ Some(1),
+ );
- assert!(mutation.is_some());
- assert!(terminal.hook_authority.is_none());
- assert_eq!(terminal.state, AgentState::Idle);
+ assert!(mutation.is_some());
+ assert!(terminal.hook_authority.is_none());
+ assert!(!terminal.full_lifecycle_hook_authority_active());
+ assert_eq!(terminal.state, AgentState::Idle);
- terminal.set_detected_state_with_screen_signals_at(
- Some(Agent::Codex),
- AgentState::Working,
- false,
- false,
- false,
- false,
- Instant::now(),
- );
+ terminal.set_detected_state_with_screen_signals_at(
+ Some(agent),
+ AgentState::Working,
+ false,
+ false,
+ false,
+ false,
+ Instant::now(),
+ );
- assert_eq!(terminal.state, AgentState::Working);
+ assert_eq!(terminal.state, AgentState::Working);
+ }
}
#[test]
diff --git a/tests/cli_wrapper.rs b/tests/cli_wrapper.rs
index 33cea043..53d91c43 100644
--- a/tests/cli_wrapper.rs
+++ b/tests/cli_wrapper.rs
@@ -544,7 +544,29 @@ fn run_copilot_hook(hook_input: &str) -> Option<serde_json::Value> {
)
}
+fn run_devin_hook(
+ action: &str,
+ hook_input: &str,
+ envs: &[(&str, &str)],
+) -> Option<serde_json::Value> {
+ run_shell_hook_with_env(
+ "src/integration/assets/devin/herdr-agent-state.sh",
+ &[action],
+ hook_input,
+ envs,
+ )
+}
+
fn run_shell_hook(asset_path: &str, args: &[&str], hook_input: &str) -> Option<serde_json::Value> {
+ run_shell_hook_with_env(asset_path, args, hook_input, &[])
+}
+
+fn run_shell_hook_with_env(
+ asset_path: &str,
+ args: &[&str],
+ hook_input: &str,
+ envs: &[(&str, &str)],
+) -> Option<serde_json::Value> {
let base = unique_test_dir();
fs::create_dir_all(&base).unwrap();
let socket_path = base.join("herdr.sock");
@@ -574,7 +596,8 @@ fn run_shell_hook(asset_path: &str, args: &[&str], hook_input: &str) -> Option<s
});
let hook_path = Path::new(env!("CARGO_MANIFEST_DIR")).join(asset_path);
- let mut child = Command::new("bash")
+ let mut command = Command::new("bash");
+ command
.arg(hook_path)
.args(args)
.env("HERDR_ENV", "1")
@@ -582,9 +605,11 @@ fn run_shell_hook(asset_path: &str, args: &[&str], hook_input: &str) -> Option<s
.env("HERDR_PANE_ID", "p_test")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
- .stderr(Stdio::piped())
- .spawn()
- .unwrap();
+ .stderr(Stdio::piped());
+ for (key, value) in envs {
+ command.env(key, value);
+ }
+ let mut child = command.spawn().unwrap();
let mut stdin = child.stdin.take().unwrap();
stdin.write_all(hook_input.as_bytes()).unwrap();
drop(stdin);
@@ -696,6 +721,113 @@ fn copilot_hook_does_not_report_lifecycle_state() {
}
#[test]
+fn devin_hook_ignores_prompt_session_list_fallback() {
+ let request = run_devin_hook(
+ "session",
+ r#"{"hook_event_name":"UserPromptSubmit","prompt":"run tests"}"#,
+ &[
+ ("DEVIN_PROJECT_DIR", "/tmp/project"),
+ (
+ "HERDR_DEVIN_LIST_JSON",
+ r#"[{"id":"older-session","working_directory":"/tmp/other"},{"id":"devin-session","working_directory":"/tmp/project"}]"#,
+ ),
+ ],
+ );
+
+ assert!(request.is_none());
+}
+
+#[test]
+fn devin_hook_reports_session_id_from_stdin_without_state() {
+ let request = run_devin_hook(
+ "session",
+ r#"{"hook_event_name":"SessionStart","session_id":"devin-session","source":"startup"}"#,
+ &[("HERDR_DEVIN_LIST_JSON", r#"[{"id":"older-session"}]"#)],
+ )
+ .expect("devin session start should report session identity");
+
+ assert_eq!(request["method"], "pane.report_agent_session");
+ assert_eq!(request["params"]["agent"], "devin");
+ assert_eq!(request["params"]["agent_session_id"], "devin-session");
+ assert!(request["params"].get("state").is_none());
+}
+
+#[test]
+fn devin_hook_prefers_hook_session_id_over_list() {
+ let request = run_devin_hook(
+ "session",
+ r#"{"hook_event_name":"PreToolUse","sessionId":"fresh-session","tool_name":"exec"}"#,
+ &[
+ ("DEVIN_PROJECT_DIR", "/tmp/project"),
+ (
+ "HERDR_DEVIN_LIST_JSON",
+ r#"[{"id":"older-session","working_directory":"/tmp/project"}]"#,
+ ),
+ ],
+ )
+ .expect("devin tool hook should report session identity");
+
+ assert_eq!(request["method"], "pane.report_agent_session");
+ assert_eq!(request["params"]["agent_session_id"], "fresh-session");
+ assert!(request["params"].get("state").is_none());
+}
+
+#[test]
+fn devin_hook_reports_tool_session_from_list_without_state() {
+ let request = run_devin_hook(
+ "session",
+ r#"{"hook_event_name":"PreToolUse","tool_name":"exec"}"#,
+ &[
+ ("DEVIN_PROJECT_DIR", "/tmp/project"),
+ (
+ "HERDR_DEVIN_LIST_JSON",
+ r#"[{"id":"older-session","working_directory":"/tmp/other"},{"id":"devin-session","working_directory":"/tmp/project"}]"#,
+ ),
+ ],
+ )
+ .expect("devin tool hook should report session identity");
+
+ assert_eq!(request["method"], "pane.report_agent_session");
+ assert_eq!(request["params"]["agent"], "devin");
+ assert_eq!(request["params"]["agent_session_id"], "devin-session");
+ assert!(request["params"].get("state").is_none());
+}
+
+#[test]
+fn devin_hook_ignores_startup_session_list_fallback() {
+ let request = run_devin_hook(
+ "session",
+ r#"{"hook_event_name":"SessionStart","source":"startup"}"#,
+ &[
+ ("DEVIN_PROJECT_DIR", "/tmp/project"),
+ (
+ "HERDR_DEVIN_LIST_JSON",
+ r#"[{"id":"stale-session","working_directory":"/tmp/project"}]"#,
+ ),
+ ],
+ );
+
+ assert!(request.is_none());
+}
+
+#[test]
+fn devin_hook_ignores_non_matching_session_list_entries() {
+ let request = run_devin_hook(
+ "session",
+ r#"{"hook_event_name":"PreToolUse","tool_name":"exec"}"#,
+ &[
+ ("DEVIN_PROJECT_DIR", "/tmp/project"),
+ (
+ "HERDR_DEVIN_LIST_JSON",
+ r#"[{"id":"other-session","working_directory":"/tmp/other"}]"#,
+ ),
+ ],
+ );
+
+ assert!(request.is_none());
+}
+
+#[test]
fn pane_run_sends_one_send_input_request_with_enter_key() {
let base = unique_test_dir();
fs::create_dir_all(&base).unwrap();