aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorqer <wbxl2000@outlook.com>2026-06-04 18:52:29 +0800
committerGitHub <noreply@github.com>2026-06-04 13:52:29 +0300
commit66b0f6b2d6e1c63c3a7aa0d26cff91cf750232aa (patch)
treec114ee4571705357540d7df3ac94349234c77ff3
parentcb18fe52b55f1a627387c829074e9d24b9cefb28 (diff)
feat: add kimi code cli integration (#463)
* feat: add kimi code cli integration refs #431 * fix: improve kimi code screen detection refs #431
-rw-r--r--docs/next/README.md7
-rw-r--r--docs/next/website/src/content/docs/agents.mdx3
-rw-r--r--docs/next/website/src/content/docs/cli-reference.mdx2
-rw-r--r--docs/next/website/src/content/docs/integrations.mdx24
-rw-r--r--src/api/schema.rs1
-rw-r--r--src/cli/integration.rs9
-rw-r--r--src/detect/agents/kimi.rs101
-rw-r--r--src/detect/agents/mod.rs3
-rw-r--r--src/detect/mod.rs40
-rw-r--r--src/integration/assets/kimi/herdr-agent-state.sh76
-rw-r--r--src/integration/mod.rs402
11 files changed, 655 insertions, 13 deletions
diff --git a/docs/next/README.md b/docs/next/README.md
index 828cbed1..84bfff66 100644
--- a/docs/next/README.md
+++ b/docs/next/README.md
@@ -145,7 +145,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, and opencode integrations provide session restore identity; pi, omp, github copilot cli, hermes, qodercli, 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, and opencode integrations provide session restore identity; pi, omp, github copilot cli, kimi code cli, hermes, qodercli, and custom socket integrations can report their own state.
## lives in your terminal
@@ -192,7 +192,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, and opencode report session identity for native restore, while their state still comes from screen detection. pi, github copilot cli, and hermes report both semantic state and session identity. omp and qodercli report semantic state without native session restore. install with:
+official integrations have two roles. claude code, codex, and opencode report session identity for native restore, while their state still comes from screen detection. pi, github copilot cli, and hermes report both semantic state and session identity. omp, kimi code cli, and qodercli report semantic state without native session restore. install with:
```bash
herdr integration install pi
@@ -200,6 +200,7 @@ herdr integration install omp
herdr integration install claude
herdr integration install codex
herdr integration install copilot
+herdr integration install kimi
herdr integration install opencode
herdr integration install hermes
herdr integration install qodercli
@@ -248,7 +249,7 @@ In-app settings cover theme, sound, and toast preferences. Herdr writes logs und
- [install](https://herdr.dev/docs/install/) โ€” install, update, Homebrew, mise, and Nix
- [session state](https://herdr.dev/docs/session-state/) โ€” detach, restart restore, agent restore, and live handoff
- [configuration](https://herdr.dev/docs/configuration/) โ€” keybindings, themes, notifications, environment variables
-- [integrations](https://herdr.dev/docs/integrations/) โ€” pi, omp, claude code, codex, github copilot cli, opencode, hermes, qodercli integrations
+- [integrations](https://herdr.dev/docs/integrations/) โ€” pi, omp, claude code, codex, github copilot cli, kimi code cli, opencode, hermes, qodercli integrations
- [`SKILL.md`](./SKILL.md) โ€” reusable agent skill
- [socket api](https://herdr.dev/docs/socket-api/) โ€” socket protocol and cli reference
diff --git a/docs/next/website/src/content/docs/agents.mdx b/docs/next/website/src/content/docs/agents.mdx
index a894d05f..b9d7e8ec 100644
--- a/docs/next/website/src/content/docs/agents.mdx
+++ b/docs/next/website/src/content/docs/agents.mdx
@@ -39,7 +39,7 @@ Herdr combines three signals:
2. terminal output heuristics
3. integration session identity or state reports
-Process detection tells Herdr which pane owns an agent. Heuristics infer native agent state from terminal screen snapshots. Claude Code, Codex, and OpenCode integrations provide session identity for restore; Pi, OMP, GitHub Copilot CLI, Hermes Agent, Qoder CLI, and custom socket integrations can report state when they define state outside the native terminal UI.
+Process detection tells Herdr which pane owns an agent. Heuristics infer native agent state from terminal screen snapshots. Claude Code, Codex, and OpenCode integrations provide session identity for restore; Pi, OMP, GitHub Copilot CLI, Kimi Code CLI, Hermes Agent, Qoder CLI, and custom socket integrations can report state when they define state outside the native terminal UI.
## State rollups
@@ -59,6 +59,7 @@ herdr integration install omp
herdr integration install claude
herdr integration install codex
herdr integration install copilot
+herdr integration install kimi
herdr integration install opencode
herdr integration install hermes
herdr integration install qodercli
diff --git a/docs/next/website/src/content/docs/cli-reference.mdx b/docs/next/website/src/content/docs/cli-reference.mdx
index dd68c0e5..50ed8c34 100644
--- a/docs/next/website/src/content/docs/cli-reference.mdx
+++ b/docs/next/website/src/content/docs/cli-reference.mdx
@@ -211,6 +211,7 @@ herdr integration install omp
herdr integration install claude
herdr integration install codex
herdr integration install copilot
+herdr integration install kimi
herdr integration install opencode
herdr integration install hermes
herdr integration install qodercli
@@ -219,6 +220,7 @@ herdr integration uninstall omp
herdr integration uninstall claude
herdr integration uninstall codex
herdr integration uninstall copilot
+herdr integration uninstall kimi
herdr integration uninstall opencode
herdr integration uninstall hermes
herdr integration uninstall qodercli
diff --git a/docs/next/website/src/content/docs/integrations.mdx b/docs/next/website/src/content/docs/integrations.mdx
index 9ef4a53b..79ec5c94 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, OpenCode, Hermes Agent, and Qoder CLI.
+description: Install Herdr integrations for Pi, OMP, Claude Code, Codex, GitHub Copilot CLI, Kimi Code CLI, OpenCode, Hermes Agent, and Qoder CLI.
---
Herdr detects supported agents automatically. Official integrations can add native session identity for restore, semantic state reports, or both. Claude Code, Codex, and OpenCode state comes from screen detection even when their session identity integrations are installed.
-Use integrations when you want native agent session restore, direct state reports from Pi/OMP/Copilot/Hermes/Qoder-style hooks or plugins, or both.
+Use integrations when you want native agent session restore, direct state reports from Pi/OMP/Copilot/Kimi/Hermes/Qoder-style hooks or plugins, or both.
## 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 kimi
herdr integration install opencode
herdr integration install hermes
herdr integration install qodercli
@@ -30,6 +31,7 @@ herdr integration uninstall omp
herdr integration uninstall claude
herdr integration uninstall codex
herdr integration uninstall copilot
+herdr integration uninstall kimi
herdr integration uninstall opencode
herdr integration uninstall hermes
herdr integration uninstall qodercli
@@ -45,11 +47,11 @@ Herdr combines three signals:
| Screen heuristics | Infers visible native agent state such as `working` or `blocked`. |
| Integration events | Reports native session identity, semantic state, or both depending on the integration. |
-Claude Code, Codex, and OpenCode state detection is screen-read first. Their Herdr-owned integrations do not author `idle`, `working`, or `blocked` state. Pi, GitHub Copilot CLI, and Hermes Agent report semantic state and session identity. OMP and Qoder CLI report semantic state but do not provide native session restore. Custom socket integrations can report state when they define state that is not visible in the native terminal UI.
+Claude Code, Codex, and OpenCode state detection is screen-read first. Their Herdr-owned integrations do not author `idle`, `working`, or `blocked` state. Pi, GitHub Copilot CLI, and Hermes Agent report semantic state and session identity. OMP, Kimi Code CLI, and Qoder CLI report semantic state but do not provide native session restore. Custom socket integrations can 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, GitHub Copilot CLI, Pi, Hermes Agent, and OpenCode 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 `5`, Codex version `5`, GitHub Copilot CLI version `1`, OpenCode version `4`, or Hermes Agent version `2`. OMP integration version `2` and Qoder CLI integration version `1` report agent state only. Check installed versions with `herdr integration status`.
+Native session restore requires current Herdr integrations: Pi integration version `2`, Claude Code version `5`, Codex version `5`, GitHub Copilot CLI version `1`, OpenCode version `4`, or Hermes Agent version `2`. OMP integration version `2`, Kimi Code CLI integration version `1`, and Qoder CLI integration version `1` report agent state only. Check installed versions with `herdr integration status`.
## Pi
@@ -123,6 +125,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>`.
+## Kimi Code CLI
+
+Install the Kimi Code CLI hook:
+
+```bash
+herdr integration install kimi
+```
+
+The hook reports semantic state to Herdr when Kimi Code emits lifecycle, tool, approval, and session events. It requires Kimi Code CLI `0.8.0` or newer.
+
+Herdr uses `~/.kimi-code` by default, or `KIMI_CODE_HOME` when set. The Kimi Code config directory must already exist. Install writes `hooks/herdr-agent-state.sh` and appends Herdr-managed `[[hooks]]` entries to `config.toml`. Uninstall removes the Herdr-managed config block and deletes the hook script.
+
+Native screen heuristics remain available when the hook is not installed.
+
## OpenCode
Install the OpenCode plugin:
diff --git a/src/api/schema.rs b/src/api/schema.rs
index 483d4842..77af06b6 100644
--- a/src/api/schema.rs
+++ b/src/api/schema.rs
@@ -524,6 +524,7 @@ pub enum IntegrationTarget {
Claude,
Codex,
Copilot,
+ Kimi,
Opencode,
Hermes,
Qodercli,
diff --git a/src/cli/integration.rs b/src/cli/integration.rs
index d8eb027c..8001172e 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|opencode|hermes|qodercli>"
+ "usage: herdr integration {action} <pi|omp|claude|codex|copilot|kimi|opencode|hermes|qodercli>"
);
return Ok(None);
};
if args.len() != 1 {
eprintln!(
- "usage: herdr integration {action} <pi|omp|claude|codex|copilot|opencode|hermes|qodercli>"
+ "usage: herdr integration {action} <pi|omp|claude|codex|copilot|kimi|opencode|hermes|qodercli>"
);
return Ok(None);
}
@@ -120,13 +120,14 @@ fn parse_integration_target(
"claude" => IntegrationTarget::Claude,
"codex" => IntegrationTarget::Codex,
"copilot" => IntegrationTarget::Copilot,
+ "kimi" => IntegrationTarget::Kimi,
"opencode" => IntegrationTarget::Opencode,
"hermes" => IntegrationTarget::Hermes,
"qodercli" => IntegrationTarget::Qodercli,
_ => {
eprintln!("unknown integration target: {target}");
eprintln!(
- "currently supported: pi, omp, claude, codex, copilot, opencode, hermes, qodercli"
+ "currently supported: pi, omp, claude, codex, copilot, kimi, opencode, hermes, qodercli"
);
return Ok(None);
}
@@ -142,6 +143,7 @@ fn print_integration_help() {
eprintln!(" herdr integration install claude");
eprintln!(" herdr integration install codex");
eprintln!(" herdr integration install copilot");
+ eprintln!(" herdr integration install kimi");
eprintln!(" herdr integration install opencode");
eprintln!(" herdr integration install hermes");
eprintln!(" herdr integration install qodercli");
@@ -150,6 +152,7 @@ fn print_integration_help() {
eprintln!(" herdr integration uninstall claude");
eprintln!(" herdr integration uninstall codex");
eprintln!(" herdr integration uninstall copilot");
+ eprintln!(" herdr integration uninstall kimi");
eprintln!(" herdr integration uninstall opencode");
eprintln!(" herdr integration uninstall hermes");
eprintln!(" herdr integration uninstall qodercli");
diff --git a/src/detect/agents/kimi.rs b/src/detect/agents/kimi.rs
index 73d1a8b0..b42bf885 100644
--- a/src/detect/agents/kimi.rs
+++ b/src/detect/agents/kimi.rs
@@ -12,7 +12,23 @@ pub(super) fn detect(content: &str) -> AgentState {
AgentState::Idle
}
+pub(super) fn has_visible_blocker(content: &str) -> bool {
+ has_current_approval_panel(content) || has_question_panel(content)
+}
+
+pub(super) fn has_prompt_box(content: &str) -> bool {
+ has_editor_prompt_box(content) && has_footer_context(content)
+}
+
+pub(super) fn has_visible_working(content: &str) -> bool {
+ has_kimi_working_status(content)
+}
+
fn has_kimi_blocked_prompt(content: &str) -> bool {
+ if has_visible_blocker(content) {
+ return true;
+ }
+
let lower = content.to_lowercase();
lower.contains("requesting approval")
&& (lower.contains("approve once") || lower.contains("approve for this session"))
@@ -43,6 +59,89 @@ fn has_kimi_working_status(content: &str) -> bool {
.trim_start_matches(|c| ('\u{2800}'..='\u{28FF}').contains(&c))
.trim_start()
.to_lowercase();
- rest.starts_with("thinking...") || rest.starts_with("using ")
+ rest.starts_with("thinking...")
+ || rest.starts_with("working...")
+ || rest.starts_with("using ")
})
}
+
+fn has_current_approval_panel(content: &str) -> bool {
+ let lower = content.to_lowercase();
+ has_approval_title(&lower)
+ && has_numeric_choose_hint(&lower)
+ && lower.contains("โ†ต confirm")
+ && (lower.contains("approve") || lower.contains("reject") || lower.contains("revise"))
+}
+
+fn has_approval_title(lower_content: &str) -> bool {
+ lower_content.contains("run this command?")
+ || lower_content.contains("write this file?")
+ || lower_content.contains("apply these edits?")
+ || lower_content.contains("stop this task?")
+ || lower_content.contains("ready to build with this plan?")
+ || lower_content.lines().any(|line| {
+ let trimmed = line.trim_start_matches(|c: char| c == 'โ–ถ' || c.is_whitespace());
+ trimmed.starts_with("approve ") && trimmed.ends_with('?')
+ })
+}
+
+fn has_question_panel(content: &str) -> bool {
+ let lower = content.to_lowercase();
+ content.lines().any(|line| line.trim() == "question")
+ && content
+ .lines()
+ .any(|line| line.trim_start().starts_with("? "))
+ && lower.contains("โ†‘โ†“ select")
+ && (lower.contains("โ†ต choose") || lower.contains("โ†ต toggle") || lower.contains("โ†ต save"))
+ && lower.contains("esc cancel")
+}
+
+fn has_numeric_choose_hint(lower_content: &str) -> bool {
+ lower_content.contains(" choose") && lower_content.contains('1') && lower_content.contains('2')
+}
+
+fn has_editor_prompt_box(content: &str) -> bool {
+ let lines: Vec<&str> = content.lines().collect();
+
+ for top_index in 0..lines.len() {
+ if !is_editor_top_border(lines[top_index]) {
+ continue;
+ }
+
+ let mut saw_prompt = false;
+ for line in lines.iter().skip(top_index + 1) {
+ if is_editor_bottom_border(line) {
+ if saw_prompt {
+ return true;
+ }
+ break;
+ }
+
+ saw_prompt |= is_editor_prompt_line(line);
+ }
+ }
+
+ false
+}
+
+fn is_editor_top_border(line: &str) -> bool {
+ let trimmed = line.trim();
+ ((trimmed.starts_with('โ•ญ') && trimmed.ends_with('โ•ฎ'))
+ || (trimmed.starts_with('โ”œ') && trimmed.ends_with('โ”ค')))
+ && trimmed.contains('โ”€')
+}
+
+fn is_editor_bottom_border(line: &str) -> bool {
+ let trimmed = line.trim();
+ trimmed.starts_with('โ•ฐ') && trimmed.ends_with('โ•ฏ') && trimmed.contains('โ”€')
+}
+
+fn is_editor_prompt_line(line: &str) -> bool {
+ let trimmed = line.trim_start();
+ let inner = trimmed.strip_prefix('โ”‚').unwrap_or(trimmed).trim_start();
+ inner.starts_with('>')
+}
+
+fn has_footer_context(content: &str) -> bool {
+ content.to_lowercase().contains("context: ")
+}
diff --git a/src/detect/agents/mod.rs b/src/detect/agents/mod.rs
index a89b2f37..46a25f66 100644
--- a/src/detect/agents/mod.rs
+++ b/src/detect/agents/mod.rs
@@ -79,6 +79,7 @@ fn has_visible_blocker(agent: Agent, content: &str, state: AgentState) -> bool {
// is known to be structural and live.
Agent::Claude => claude_code::has_visible_blocker(content),
Agent::Codex => codex::has_visible_blocker(content),
+ Agent::Kimi => kimi::has_visible_blocker(content),
_ => false,
}
}
@@ -91,6 +92,7 @@ fn has_visible_idle(agent: Agent, content: &str, state: AgentState) -> bool {
match agent {
Agent::Claude => claude_code::has_prompt_box(content),
Agent::Codex => codex::has_prompt(content),
+ Agent::Kimi => kimi::has_prompt_box(content),
_ => false,
}
}
@@ -103,6 +105,7 @@ fn has_visible_working(agent: Agent, content: &str, state: AgentState) -> bool {
match agent {
Agent::Claude => claude_code::has_working_chrome(content),
Agent::Codex => codex::has_visible_working(content),
+ Agent::Kimi => kimi::has_visible_working(content),
_ => false,
}
}
diff --git a/src/detect/mod.rs b/src/detect/mod.rs
index 6240d1b2..06536336 100644
--- a/src/detect/mod.rs
+++ b/src/detect/mod.rs
@@ -2124,6 +2124,26 @@ mod tests {
}
#[test]
+ fn kimi_current_approval_panel_is_visible_blocker() {
+ let screen = "โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\n โ–ถ Run this command?\n\n $ git status --short\n\n โ–ถ 1. Approve once\n 2. Approve for this session\n 3. Reject\n 4. Reject with feedback\n\n โ†‘/โ†“ select ยท 1/2/3/4 choose ยท โ†ต confirm\nโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€";
+ let detection = detect_agent(Some(Agent::Kimi), screen);
+
+ assert_eq!(detection.state, AgentState::Blocked);
+ assert!(detection.visible_blocker);
+ assert!(!detection.visible_idle);
+ }
+
+ #[test]
+ fn kimi_question_panel_is_visible_blocker() {
+ let screen = "โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\n question\n\n (โ—‹) Destination Submit\n\n ? Which destination should I use?\n\n โ†’ [1] Local checkout\n [2] Remote branch\n [3] Other\n\n โ†‘โ†“ select 1-3 / โ†ต choose โ†/โ†’/tab switch esc cancel\nโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€";
+ let detection = detect_agent(Some(Agent::Kimi), screen);
+
+ assert_eq!(detection.state, AgentState::Blocked);
+ assert!(detection.visible_blocker);
+ assert!(!detection.visible_idle);
+ }
+
+ #[test]
fn kimi_approval_words_without_prompt_stay_idle() {
assert_eq!(detect_kimi("approve?"), AgentState::Idle);
assert_eq!(detect_kimi("continue? [y/n]"), AgentState::Idle);
@@ -2146,6 +2166,15 @@ mod tests {
}
#[test]
+ fn kimi_working_braille_working_status_is_visible_working() {
+ let detection = detect_agent(Some(Agent::Kimi), "โ ‹ working...");
+
+ assert_eq!(detection.state, AgentState::Working);
+ assert!(detection.visible_working);
+ assert!(!detection.visible_idle);
+ }
+
+ #[test]
fn kimi_working_moon_spinner() {
assert_eq!(detect_kimi("๐ŸŒ•"), AgentState::Working);
assert_eq!(detect_kimi("๐ŸŒ—"), AgentState::Working);
@@ -2175,6 +2204,17 @@ mod tests {
assert_eq!(detect_kimi(screen), AgentState::Idle);
}
+ #[test]
+ fn kimi_current_editor_box_is_visible_idle() {
+ let screen = "โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ\nโ”‚ > โ”‚\nโ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ\nk2 ~/Projects/herdr /help: show commands\n context: 0.0%";
+ let detection = detect_agent(Some(Agent::Kimi), screen);
+
+ assert_eq!(detection.state, AgentState::Idle);
+ assert!(detection.visible_idle);
+ assert!(!detection.visible_working);
+ assert!(!detection.visible_blocker);
+ }
+
// ---- Kiro ----
#[test]
diff --git a/src/integration/assets/kimi/herdr-agent-state.sh b/src/integration/assets/kimi/herdr-agent-state.sh
new file mode 100644
index 00000000..3e0a9c29
--- /dev/null
+++ b/src/integration/assets/kimi/herdr-agent-state.sh
@@ -0,0 +1,76 @@
+#!/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=kimi
+# HERDR_INTEGRATION_VERSION=1
+
+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:kimi"
+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}"
+report_seq = time.time_ns()
+if action == "release":
+ request = {
+ "id": request_id,
+ "method": "pane.release_agent",
+ "params": {
+ "pane_id": pane_id,
+ "source": source,
+ "agent": "kimi",
+ "seq": report_seq,
+ },
+ }
+else:
+ request = {
+ "id": request_id,
+ "method": "pane.report_agent",
+ "params": {
+ "pane_id": pane_id,
+ "source": source,
+ "agent": "kimi",
+ "state": action,
+ "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 31197318..f71ebce3 100644
--- a/src/integration/mod.rs
+++ b/src/integration/mod.rs
@@ -25,6 +25,25 @@ const CODEX_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh";
const CODEX_HOOK_ASSET: &str = include_str!("assets/codex/herdr-agent-state.sh");
const CODEX_INTEGRATION_VERSION: u32 = 5;
const CODEX_HOME_ENV_VAR: &str = "CODEX_HOME";
+const KIMI_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh";
+const KIMI_HOOK_ASSET: &str = include_str!("assets/kimi/herdr-agent-state.sh");
+const KIMI_INTEGRATION_VERSION: u32 = 1;
+const KIMI_CODE_HOME_ENV_VAR: &str = "KIMI_CODE_HOME";
+const KIMI_CONFIG_BLOCK_BEGIN: &str = "# >>> herdr kimi integration";
+const KIMI_CONFIG_BLOCK_END: &str = "# <<< herdr kimi integration";
+const KIMI_MIN_VERSION: &str = "0.8.0";
+const KIMI_HOOK_EVENTS: [(&str, &str); 10] = [
+ ("SessionStart", "idle"),
+ ("UserPromptSubmit", "working"),
+ ("PreToolUse", "working"),
+ ("PermissionRequest", "blocked"),
+ ("PermissionResult", "working"),
+ ("PostToolUse", "working"),
+ ("PostToolUseFailure", "working"),
+ ("Stop", "idle"),
+ ("StopFailure", "idle"),
+ ("SessionEnd", "release"),
+];
const COPILOT_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh";
const COPILOT_HOOK_ASSET: &str = include_str!("assets/copilot/herdr-agent-state.sh");
const COPILOT_INTEGRATION_VERSION: u32 = 1;
@@ -58,6 +77,12 @@ pub(crate) struct CodexInstallPaths {
}
#[derive(Debug)]
+pub(crate) struct KimiInstallPaths {
+ pub hook_path: PathBuf,
+ pub config_path: PathBuf,
+}
+
+#[derive(Debug)]
pub(crate) struct CopilotInstallPaths {
pub hook_path: PathBuf,
pub settings_path: PathBuf,
@@ -166,6 +191,14 @@ pub(crate) struct CodexUninstallResult {
}
#[derive(Debug)]
+pub(crate) struct KimiUninstallResult {
+ pub hook_path: PathBuf,
+ pub config_path: PathBuf,
+ pub removed_hook_file: bool,
+ pub updated_config: bool,
+}
+
+#[derive(Debug)]
pub(crate) struct CopilotUninstallResult {
pub hook_path: PathBuf,
pub settings_path: PathBuf,
@@ -258,6 +291,17 @@ pub(crate) fn install_target(
),
]
}
+ crate::api::schema::IntegrationTarget::Kimi => {
+ let installed = install_kimi()?;
+ vec![
+ format!(
+ "installed kimi integration hook to {}",
+ installed.hook_path.display()
+ ),
+ format!("ensured kimi config at {}", installed.config_path.display()),
+ format!("requires kimi code {KIMI_MIN_VERSION} or newer"),
+ ]
+ }
crate::api::schema::IntegrationTarget::Opencode => {
let installed = install_opencode()?;
vec![format!(
@@ -414,6 +458,33 @@ pub(crate) fn uninstall_target(
}
messages
}
+ crate::api::schema::IntegrationTarget::Kimi => {
+ let result = uninstall_kimi()?;
+ let mut messages = Vec::new();
+ if result.removed_hook_file {
+ messages.push(format!(
+ "removed kimi hook at {}",
+ result.hook_path.display()
+ ));
+ } else {
+ messages.push(format!(
+ "no kimi hook found at {}",
+ result.hook_path.display()
+ ));
+ }
+ if result.updated_config {
+ messages.push(format!(
+ "removed herdr kimi hook entries from {}",
+ result.config_path.display()
+ ));
+ } else {
+ messages.push(format!(
+ "no herdr kimi hook entries found in {}",
+ result.config_path.display()
+ ));
+ }
+ messages
+ }
crate::api::schema::IntegrationTarget::Opencode => {
let result = uninstall_opencode()?;
if result.removed_plugin {
@@ -497,6 +568,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::Kimi => "kimi",
crate::api::schema::IntegrationTarget::Opencode => "opencode",
crate::api::schema::IntegrationTarget::Hermes => "hermes",
crate::api::schema::IntegrationTarget::Qodercli => "qodercli",
@@ -510,6 +582,7 @@ fn integration_target_command(target: crate::api::schema::IntegrationTarget) ->
crate::api::schema::IntegrationTarget::Claude => "claude",
crate::api::schema::IntegrationTarget::Codex => "codex",
crate::api::schema::IntegrationTarget::Copilot => "copilot",
+ crate::api::schema::IntegrationTarget::Kimi => "kimi",
crate::api::schema::IntegrationTarget::Opencode => "opencode",
crate::api::schema::IntegrationTarget::Hermes => "hermes",
crate::api::schema::IntegrationTarget::Qodercli => "qodercli",
@@ -586,7 +659,7 @@ fn integration_specs() -> [(
crate::api::schema::IntegrationTarget,
io::Result<PathBuf>,
u32,
-); 8] {
+); 9] {
[
(
crate::api::schema::IntegrationTarget::Pi,
@@ -614,6 +687,11 @@ fn integration_specs() -> [(
COPILOT_INTEGRATION_VERSION,
),
(
+ crate::api::schema::IntegrationTarget::Kimi,
+ kimi_dir().map(|dir| dir.join("hooks").join(KIMI_HOOK_INSTALL_NAME)),
+ KIMI_INTEGRATION_VERSION,
+ ),
+ (
crate::api::schema::IntegrationTarget::Opencode,
opencode_dir().map(|dir| dir.join("plugins").join(OPENCODE_PLUGIN_INSTALL_NAME)),
OPENCODE_INTEGRATION_VERSION,
@@ -933,6 +1011,39 @@ pub(crate) fn install_codex() -> io::Result<CodexInstallPaths> {
})
}
+pub(crate) fn install_kimi() -> io::Result<KimiInstallPaths> {
+ let dir = kimi_dir()?;
+ if !dir.is_dir() {
+ return Err(io::Error::other(format!(
+ "kimi code config directory not found at {}. install kimi code first",
+ dir.display()
+ )));
+ }
+
+ let hooks_dir = dir.join("hooks");
+ fs::create_dir_all(&hooks_dir)?;
+
+ let hook_path = hooks_dir.join(KIMI_HOOK_INSTALL_NAME);
+ fs::write(&hook_path, KIMI_HOOK_ASSET)?;
+ make_executable(&hook_path)?;
+
+ let config_path = dir.join("config.toml");
+ let existing_config = if config_path.is_file() {
+ fs::read_to_string(&config_path)?
+ } else {
+ String::new()
+ };
+ let new_config = build_kimi_config_with_hooks(&existing_config, &hook_path);
+ if new_config != existing_config {
+ fs::write(&config_path, new_config)?;
+ }
+
+ Ok(KimiInstallPaths {
+ hook_path,
+ config_path,
+ })
+}
+
pub(crate) fn install_copilot() -> io::Result<CopilotInstallPaths> {
let dir = copilot_dir()?;
if !dir.is_dir() {
@@ -1220,6 +1331,31 @@ pub(crate) fn uninstall_codex() -> io::Result<CodexUninstallResult> {
})
}
+pub(crate) fn uninstall_kimi() -> io::Result<KimiUninstallResult> {
+ let kimi_dir = kimi_dir()?;
+ let hook_path = kimi_dir.join("hooks").join(KIMI_HOOK_INSTALL_NAME);
+ let config_path = kimi_dir.join("config.toml");
+ let mut updated_config = false;
+
+ if config_path.is_file() {
+ let existing_config = fs::read_to_string(&config_path)?;
+ let new_config = remove_kimi_config_block(&existing_config);
+ if new_config != existing_config {
+ fs::write(&config_path, new_config)?;
+ updated_config = true;
+ }
+ }
+
+ let removed_hook_file = remove_file_if_exists(&hook_path)?;
+
+ Ok(KimiUninstallResult {
+ hook_path,
+ config_path,
+ removed_hook_file,
+ updated_config,
+ })
+}
+
pub(crate) fn uninstall_copilot() -> io::Result<CopilotUninstallResult> {
let copilot_dir = copilot_dir()?;
let hook_path = copilot_dir.join("hooks").join(COPILOT_HOOK_INSTALL_NAME);
@@ -1878,6 +2014,95 @@ fn build_codex_config_with_hooks(content: &str) -> String {
join_toml_lines(lines, trailing_newline)
}
+fn build_kimi_config_with_hooks(content: &str, hook_path: &Path) -> String {
+ let mut result = remove_kimi_config_block(content)
+ .trim_end_matches('\n')
+ .to_string();
+ if !result.is_empty() {
+ result.push('\n');
+ result.push('\n');
+ }
+
+ result.push_str(KIMI_CONFIG_BLOCK_BEGIN);
+ result.push('\n');
+ for (event, action) in KIMI_HOOK_EVENTS {
+ result.push_str(&kimi_hook_table(event, hook_path, action));
+ }
+ result.push_str(KIMI_CONFIG_BLOCK_END);
+ result.push('\n');
+ result
+}
+
+fn kimi_hook_table(event: &str, hook_path: &Path, action: &str) -> String {
+ let command = format!(
+ "bash {} {action}",
+ shell_single_quote(&hook_path.display().to_string())
+ );
+ format!(
+ "[[hooks]]\nevent = {}\ncommand = {}\ntimeout = 10\n\n",
+ toml_basic_string(event),
+ toml_basic_string(&command)
+ )
+}
+
+fn remove_kimi_config_block(content: &str) -> String {
+ let trailing_newline = content.ends_with('\n');
+ let mut lines = Vec::new();
+ let mut in_block = false;
+ let mut removed_block = false;
+
+ for line in content.lines() {
+ if line.trim() == KIMI_CONFIG_BLOCK_BEGIN {
+ in_block = true;
+ removed_block = true;
+ continue;
+ }
+ if in_block {
+ if line.trim() == KIMI_CONFIG_BLOCK_END {
+ in_block = false;
+ }
+ continue;
+ }
+ lines.push(line.to_string());
+ }
+
+ if !removed_block {
+ return content.to_string();
+ }
+
+ let mut result = join_toml_lines(lines, trailing_newline);
+ while result.ends_with("\n\n") {
+ result.pop();
+ }
+ if result == "\n" {
+ String::new()
+ } else {
+ result
+ }
+}
+
+fn toml_basic_string(value: &str) -> String {
+ let mut result = String::with_capacity(value.len() + 2);
+ result.push('"');
+ for ch in value.chars() {
+ match ch {
+ '"' => result.push_str("\\\""),
+ '\\' => result.push_str("\\\\"),
+ '\u{08}' => result.push_str("\\b"),
+ '\t' => result.push_str("\\t"),
+ '\n' => result.push_str("\\n"),
+ '\u{0c}' => result.push_str("\\f"),
+ '\r' => result.push_str("\\r"),
+ ch if ch <= '\u{1f}' || ch == '\u{7f}' => {
+ result.push_str(&format!("\\u{:04X}", ch as u32));
+ }
+ ch => result.push(ch),
+ }
+ }
+ result.push('"');
+ result
+}
+
fn join_toml_lines(lines: Vec<String>, trailing_newline: bool) -> String {
let mut result = lines.join("\n");
if trailing_newline || result.is_empty() {
@@ -1954,6 +2179,10 @@ fn codex_dir() -> io::Result<PathBuf> {
config_dir_from_env_or_home(CODEX_HOME_ENV_VAR, &[".codex"])
}
+fn kimi_dir() -> io::Result<PathBuf> {
+ config_dir_from_env_or_home(KIMI_CODE_HOME_ENV_VAR, &[".kimi-code"])
+}
+
fn copilot_dir() -> io::Result<PathBuf> {
config_dir_from_env_or_home(COPILOT_HOME_ENV_VAR, &[".copilot"])
}
@@ -2032,9 +2261,39 @@ mod tests {
std::env::remove_var(CLAUDE_CONFIG_DIR_ENV_VAR);
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(QODERCLI_CONFIG_DIR_ENV_VAR);
}
+ fn kimi_hook_command(hook_path: &Path, action: &str) -> String {
+ format!(
+ "bash {} {action}",
+ shell_single_quote(&hook_path.display().to_string())
+ )
+ }
+
+ fn kimi_config_hooks(config: &str) -> Vec<toml::Value> {
+ let parsed: toml::Value = toml::from_str(config).unwrap();
+ parsed
+ .get("hooks")
+ .and_then(toml::Value::as_array)
+ .cloned()
+ .unwrap_or_default()
+ }
+
+ fn assert_kimi_hook(config: &str, hook_path: &Path, event: &str, action: &str) {
+ let command = kimi_hook_command(hook_path, action);
+ let hooks = kimi_config_hooks(config);
+ assert!(
+ hooks.iter().any(|hook| {
+ hook.get("event").and_then(toml::Value::as_str) == Some(event)
+ && hook.get("command").and_then(toml::Value::as_str) == Some(command.as_str())
+ && hook.get("timeout").and_then(toml::Value::as_integer) == Some(10)
+ }),
+ "missing kimi hook for {event} -> {action}"
+ );
+ }
+
fn unique_base() -> PathBuf {
clear_integration_path_env();
std::env::temp_dir().join(format!(
@@ -2875,6 +3134,143 @@ mod tests {
}
#[test]
+ fn install_kimi_writes_hook_and_updates_config() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let kimi_dir = home.join(".kimi-code");
+ fs::create_dir_all(&kimi_dir).unwrap();
+ fs::write(
+ kimi_dir.join("config.toml"),
+ "default_model = \"moonshot\"\n\n[[hooks]]\nevent = \"Notification\"\nmatcher = \"task.completed\"\ncommand = \"echo keep\"\ntimeout = 3\n",
+ )
+ .unwrap();
+ std::env::set_var("HOME", &home);
+
+ let installed = install_kimi().unwrap();
+ let hook_content = fs::read_to_string(&installed.hook_path).unwrap();
+ let config = fs::read_to_string(&installed.config_path).unwrap();
+ let hooks = kimi_config_hooks(&config);
+
+ assert_eq!(
+ installed.hook_path,
+ kimi_dir.join("hooks").join(KIMI_HOOK_INSTALL_NAME)
+ );
+ assert_eq!(installed.config_path, kimi_dir.join("config.toml"));
+ assert_eq!(hook_content, KIMI_HOOK_ASSET);
+ assert_eq!(hooks.len(), 11);
+ assert!(config.contains("default_model = \"moonshot\""));
+ assert!(config.contains("command = \"echo keep\""));
+ assert!(config.contains(KIMI_CONFIG_BLOCK_BEGIN));
+ assert!(config.contains(KIMI_CONFIG_BLOCK_END));
+ for (event, action) in KIMI_HOOK_EVENTS {
+ assert_kimi_hook(&config, &installed.hook_path, event, action);
+ }
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+ }
+
+ #[test]
+ fn install_kimi_uses_kimi_code_home_env() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let kimi_dir = base.join("custom-kimi");
+ fs::create_dir_all(&kimi_dir).unwrap();
+ std::env::set_var(KIMI_CODE_HOME_ENV_VAR, &kimi_dir);
+
+ let installed = install_kimi().unwrap();
+
+ assert_eq!(
+ installed.hook_path,
+ kimi_dir.join("hooks").join(KIMI_HOOK_INSTALL_NAME)
+ );
+ assert_eq!(installed.config_path, kimi_dir.join("config.toml"));
+
+ clear_integration_path_env();
+ let _ = fs::remove_dir_all(base);
+ }
+
+ #[test]
+ fn install_kimi_is_idempotent_for_config_block() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let kimi_dir = home.join(".kimi-code");
+ fs::create_dir_all(&kimi_dir).unwrap();
+ std::env::set_var("HOME", &home);
+
+ install_kimi().unwrap();
+ install_kimi().unwrap();
+
+ let config = fs::read_to_string(kimi_dir.join("config.toml")).unwrap();
+ let hooks = kimi_config_hooks(&config);
+
+ assert_eq!(config.matches(KIMI_CONFIG_BLOCK_BEGIN).count(), 1);
+ assert_eq!(config.matches(KIMI_CONFIG_BLOCK_END).count(), 1);
+ assert_eq!(hooks.len(), KIMI_HOOK_EVENTS.len());
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+ }
+
+ #[test]
+ fn uninstall_kimi_removes_hook_and_config_block_preserves_other_hooks() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let kimi_dir = home.join(".kimi-code");
+ fs::create_dir_all(&kimi_dir).unwrap();
+ std::env::set_var("HOME", &home);
+
+ let installed = install_kimi().unwrap();
+ fs::write(
+ &installed.config_path,
+ format!(
+ "default_model = \"moonshot\"\n\n[[hooks]]\nevent = \"Notification\"\ncommand = \"echo keep\"\n\n{}",
+ fs::read_to_string(&installed.config_path).unwrap()
+ ),
+ )
+ .unwrap();
+
+ let result = uninstall_kimi().unwrap();
+ let config = fs::read_to_string(kimi_dir.join("config.toml")).unwrap();
+ let hooks = kimi_config_hooks(&config);
+
+ assert!(result.removed_hook_file);
+ assert!(result.updated_config);
+ assert!(!result.hook_path.exists());
+ assert!(config.contains("default_model = \"moonshot\""));
+ assert!(config.contains("command = \"echo keep\""));
+ assert!(!config.contains(KIMI_CONFIG_BLOCK_BEGIN));
+ assert!(!config.contains(KIMI_CONFIG_BLOCK_END));
+ assert_eq!(hooks.len(), 1);
+ assert_eq!(
+ hooks[0].get("event").and_then(toml::Value::as_str),
+ Some("Notification")
+ );
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+ }
+
+ #[test]
+ fn install_kimi_errors_when_config_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_kimi().unwrap_err().to_string();
+
+ assert!(err.contains("kimi code config directory not found"));
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+ }
+
+ #[test]
fn install_copilot_writes_hook_and_updates_settings() {
let _lock = integration_env_lock();
let base = unique_base();
@@ -3243,6 +3639,10 @@ mod tests {
assert!(CODEX_HOOK_ASSET.contains("pane.report_agent_session"));
assert!(!CODEX_HOOK_ASSET.contains("\"state\": action"));
assert!(!CODEX_HOOK_ASSET.contains("pane.release_agent"));
+ assert!(KIMI_HOOK_ASSET.contains("source = \"herdr:kimi\""));
+ assert!(KIMI_HOOK_ASSET.contains("pane.report_agent"));
+ assert!(KIMI_HOOK_ASSET.contains("pane.release_agent"));
+ assert!(!KIMI_HOOK_ASSET.contains("agent_session_id"));
assert!(COPILOT_HOOK_ASSET.contains("agent_session_id"));
assert!(COPILOT_HOOK_ASSET.contains("notification_type"));
assert!(COPILOT_HOOK_ASSET.contains("ask_user"));