feat: add Qoder CLI integration (#309)
* feat: add qodercli agent integration Recognize qodercli as an agent in herdr panes, with basic Idle/Working/ Blocked screen-state detection mirroring the existing claude / codex patterns. Adds an IntegrationTarget::Qodercli variant, install_target wiring for shell hooks under src/integration/assets/qodercli/, the herdr integration install qodercli CLI surface, and sound-override coverage so qodercli notifications follow the same routing rules as other agents. refs #308 * fix: detect qodercli permission required as blocked refs #308 * fix: detect qodercli ask-user prompt as blocked refs #308 * refactor: extract has_qodercli_blocked_prompt helper aligning detect_claude refs #308 * fix: tighten qodercli working state detection Combine spinner-row strictness and explicit cancel-hint matching, and stop matching the bare word 'working'. This avoids false positives when narrative output (logs, commit messages, Markdown) contains the word 'working' or when a stale braille glyph is sitting in the scrollback from a previous frame. - has_qodercli_spinner_row: require row-start braille glyph + space + alphabetic char so only an active spinner row triggers Working. - has_qodercli_working_hint: match '(esc to cancel,' as a positive signal that survives spinner masking (e.g. when a hook icon replaces the spinner glyph). refs #308 * fix: short-circuit qodercli press-again banners to idle refs #308 * fix: detect qodercli AskUser dialog by its 'Asking User' title refs #308 * feat: recognize qodercli, qoderclicn, qoder, qodercn process names Map qodercli, qoderclicn, qoder, and qodercn process names all to the Qodercli agent. Covers both international (qodercli/qoder) and CN (qoderclicn/qodercn) Qoder CLI binary names. refs #308 * feat: register qodercli hook in qoder settings.json with stdin payload Address the integration-layer feedback on #308: - `herdr integration install qodercli` now writes the bundled hook into ~/.qoder/hooks/herdr-agent-state.sh AND registers it in ~/.qoder/settings.json under the schema documented at https://docs.qoder.com/zh/cli/hooks (and confirmed against the packages/core/src/hooks/types.ts type definitions in qodercli's own source tree). The hooks object is keyed by event name — SessionStart / UserPromptSubmit / PreToolUse / PermissionRequest / Stop / SessionEnd — with each entry holding a matcher and a list of `{ type: "command", command, timeout }` invocations, mirroring how install_claude already wires claude code's settings.json. - The bundled assets/qodercli/herdr-agent-state.sh now reads the hook payload from stdin (per HookInput in qodercli's types.ts: session_id, transcript_path, cwd, hook_event_name, agent_id, ...). No environment variable is consulted to identify the event; SubagentStop and subagent release events return early so a recap or away-summary frame cannot revive an idle pane, mirroring assets/claude/herdr-agent-state.sh. - uninstall_qodercli reverses the same set of entries while preserving any foreign hook entries the user may have configured by hand. - Tests cover write / idempotency / uninstall-preserves-others and lock the stdin contract via a bundled-asset assertion. refs #308 --------- Co-authored-by: coderwayne <jlu1318079810@163.com> Co-authored-by: Can Celik <ogulcancelik@gmail.com>
This commit is contained in:
parent
858c36974b
commit
bfb9e756a0
|
|
@ -510,6 +510,7 @@ pub enum IntegrationTarget {
|
|||
Codex,
|
||||
Opencode,
|
||||
Hermes,
|
||||
Qodercli,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
|
|
|||
|
|
@ -102,11 +102,15 @@ fn parse_integration_target(
|
|||
action: &str,
|
||||
) -> std::io::Result<Option<IntegrationTarget>> {
|
||||
let Some(target) = args.first().map(|arg| arg.as_str()) else {
|
||||
eprintln!("usage: herdr integration {action} <pi|omp|claude|codex|opencode|hermes>");
|
||||
eprintln!(
|
||||
"usage: herdr integration {action} <pi|omp|claude|codex|opencode|hermes|qodercli>"
|
||||
);
|
||||
return Ok(None);
|
||||
};
|
||||
if args.len() != 1 {
|
||||
eprintln!("usage: herdr integration {action} <pi|omp|claude|codex|opencode|hermes>");
|
||||
eprintln!(
|
||||
"usage: herdr integration {action} <pi|omp|claude|codex|opencode|hermes|qodercli>"
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
|
|
@ -117,9 +121,10 @@ fn parse_integration_target(
|
|||
"codex" => IntegrationTarget::Codex,
|
||||
"opencode" => IntegrationTarget::Opencode,
|
||||
"hermes" => IntegrationTarget::Hermes,
|
||||
"qodercli" => IntegrationTarget::Qodercli,
|
||||
_ => {
|
||||
eprintln!("unknown integration target: {target}");
|
||||
eprintln!("currently supported: pi, omp, claude, codex, opencode, hermes");
|
||||
eprintln!("currently supported: pi, omp, claude, codex, opencode, hermes, qodercli");
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
|
|
@ -135,11 +140,13 @@ fn print_integration_help() {
|
|||
eprintln!(" herdr integration install codex");
|
||||
eprintln!(" herdr integration install opencode");
|
||||
eprintln!(" herdr integration install hermes");
|
||||
eprintln!(" herdr integration install qodercli");
|
||||
eprintln!(" herdr integration uninstall pi");
|
||||
eprintln!(" herdr integration uninstall omp");
|
||||
eprintln!(" herdr integration uninstall claude");
|
||||
eprintln!(" herdr integration uninstall codex");
|
||||
eprintln!(" herdr integration uninstall opencode");
|
||||
eprintln!(" herdr integration uninstall hermes");
|
||||
eprintln!(" herdr integration uninstall qodercli");
|
||||
eprintln!(" herdr integration status [--outdated-only]");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ pub struct AgentSoundOverrides {
|
|||
pub amp: AgentSoundSetting,
|
||||
pub grok: AgentSoundSetting,
|
||||
pub hermes: AgentSoundSetting,
|
||||
pub qodercli: AgentSoundSetting,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
|
||||
|
|
@ -130,6 +131,7 @@ impl AgentSoundOverrides {
|
|||
Some(Agent::Amp) => self.amp,
|
||||
Some(Agent::Grok) => self.grok,
|
||||
Some(Agent::Hermes) => self.hermes,
|
||||
Some(Agent::Qodercli) => self.qodercli,
|
||||
None => AgentSoundSetting::Default,
|
||||
}
|
||||
}
|
||||
|
|
@ -165,6 +167,7 @@ impl Default for AgentSoundOverrides {
|
|||
amp: AgentSoundSetting::Default,
|
||||
grok: AgentSoundSetting::Default,
|
||||
hermes: AgentSoundSetting::Default,
|
||||
qodercli: AgentSoundSetting::Default,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
255
src/detect.rs
255
src/detect.rs
|
|
@ -52,6 +52,7 @@ pub enum Agent {
|
|||
Amp,
|
||||
Grok,
|
||||
Hermes,
|
||||
Qodercli,
|
||||
}
|
||||
|
||||
pub fn agent_label(agent: Agent) -> &'static str {
|
||||
|
|
@ -71,6 +72,7 @@ pub fn agent_label(agent: Agent) -> &'static str {
|
|||
Agent::Amp => "amp",
|
||||
Agent::Grok => "grok",
|
||||
Agent::Hermes => "hermes",
|
||||
Agent::Qodercli => "qodercli",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -92,6 +94,7 @@ pub fn parse_agent_label(agent: &str) -> Option<Agent> {
|
|||
"amp" | "amp-local" => Some(Agent::Amp),
|
||||
"grok" | "grok-build" => Some(Agent::Grok),
|
||||
"hermes" | "hermes-agent" => Some(Agent::Hermes),
|
||||
"qodercli" | "qoderclicn" | "qoder" | "qodercn" => Some(Agent::Qodercli),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
|
@ -117,6 +120,7 @@ pub fn identify_agent(process_name: &str) -> Option<Agent> {
|
|||
"amp" | "amp-local" => Some(Agent::Amp),
|
||||
"grok" | "grok-build" => Some(Agent::Grok),
|
||||
"hermes" | "hermes-agent" => Some(Agent::Hermes),
|
||||
"qodercli" | "qoderclicn" | "qoder" | "qodercn" => Some(Agent::Qodercli),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
|
@ -184,6 +188,7 @@ pub fn detect_agent(agent: Option<Agent>, screen_content: &str) -> AgentDetectio
|
|||
Agent::Amp => detect_amp(screen_content),
|
||||
Agent::Grok => detect_grok(screen_content),
|
||||
Agent::Hermes => detect_hermes(screen_content),
|
||||
Agent::Qodercli => detect_qodercli(screen_content),
|
||||
};
|
||||
AgentDetection {
|
||||
state,
|
||||
|
|
@ -628,6 +633,104 @@ fn has_kiro_tool_spinner(content: &str) -> bool {
|
|||
})
|
||||
}
|
||||
|
||||
/// Qodercli detection.
|
||||
///
|
||||
/// Qodercli is a Node.js coding-agent CLI. It surfaces a confirmation prompt
|
||||
/// while awaiting tool approval and a braille spinner while working.
|
||||
fn detect_qodercli(content: &str) -> AgentState {
|
||||
let lower = content.to_lowercase();
|
||||
|
||||
// Idle short-circuit: double-press confirmation hints render *over* the
|
||||
// input prompt while the user briefly holds Ctrl+C / Esc. The pane is
|
||||
// effectively idle there — without this, a stale spinner row above could
|
||||
// still flip it to Working.
|
||||
if has_qodercli_idle_override(&lower) {
|
||||
return AgentState::Idle;
|
||||
}
|
||||
|
||||
if has_qodercli_blocked_prompt(&lower) {
|
||||
return AgentState::Blocked;
|
||||
}
|
||||
|
||||
// Working: explicit "(esc to cancel, …)" hint or an active spinner row.
|
||||
if has_qodercli_working_hint(&lower) || has_qodercli_spinner_row(content) {
|
||||
return AgentState::Working;
|
||||
}
|
||||
|
||||
AgentState::Idle
|
||||
}
|
||||
|
||||
/// Idle override hints. Mirrors the `⌕ Search…` / `ctrl+r to toggle` shortcut
|
||||
/// in [`detect_claude`]: when these UI bits are visible the pane is sitting at
|
||||
/// a static prompt and should not be classified as Working or Blocked.
|
||||
///
|
||||
/// Covers qodercli's "press again" exit/rewind banners.
|
||||
fn has_qodercli_idle_override(lower_content: &str) -> bool {
|
||||
lower_content.contains("press ctrl+c again to exit")
|
||||
|| lower_content.contains("press ctrl+d again to exit")
|
||||
|| lower_content.contains("press esc again to rewind")
|
||||
}
|
||||
|
||||
/// Working hints qodercli prints alongside the spinner while the model is
|
||||
/// responding. The "(esc to cancel, …)" suffix is unique to qodercli's loading
|
||||
/// indicator and survives even when the spinner glyph is masked (e.g. by
|
||||
/// a hook icon).
|
||||
fn has_qodercli_working_hint(lower_content: &str) -> bool {
|
||||
lower_content.contains("(esc to cancel,")
|
||||
}
|
||||
|
||||
/// Strict spinner-row detection for qodercli.
|
||||
///
|
||||
/// Matches a line whose first non-whitespace glyph is a braille pattern
|
||||
/// (U+2800–U+28FF, the cli-spinners "dots" set qodercli renders), followed by
|
||||
/// a space and at least one alphabetic character on the same line. This avoids
|
||||
/// flagging the pane as Working when the scrollback merely contains a stale
|
||||
/// braille glyph from an earlier frame.
|
||||
fn has_qodercli_spinner_row(content: &str) -> bool {
|
||||
for line in content.lines() {
|
||||
let trimmed = line.trim_start();
|
||||
let mut chars = trimmed.chars();
|
||||
let Some(first) = chars.next() else {
|
||||
continue;
|
||||
};
|
||||
if !('\u{2800}'..='\u{28FF}').contains(&first) {
|
||||
continue;
|
||||
}
|
||||
let rest: String = chars.collect();
|
||||
if rest.starts_with(' ') && rest.chars().any(|c| c.is_alphabetic()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Blocked patterns specific to qodercli.
|
||||
///
|
||||
/// Mirrors the helper structure used by [`has_claude_blocked_prompt`] so the
|
||||
/// pattern surface stays a single, easy-to-extend list.
|
||||
///
|
||||
/// Covered states:
|
||||
/// * Tool-call confirmation banners ("Waiting for user confirmation",
|
||||
/// "Awaiting approval").
|
||||
/// * The "Permission Required / Allow once or always?" approval dialog.
|
||||
/// * The `ask-user` tool's interactive prompt. "Asking User" is the dialog's
|
||||
/// stable BaseTabDialog title and covers every form (single-select,
|
||||
/// multi-select, free-form input, review tab). The "Enter your response"
|
||||
/// placeholder and "Review your answers:" review heading are kept as
|
||||
/// defensive fallbacks in case the title row scrolls off-screen.
|
||||
/// * The interactive shell waiting hint emitted by qodercli when an agent
|
||||
/// spawns a shell that is now parked for user keystrokes.
|
||||
fn has_qodercli_blocked_prompt(lower_content: &str) -> bool {
|
||||
lower_content.contains("waiting for user confirmation")
|
||||
|| lower_content.contains("awaiting approval")
|
||||
|| lower_content.contains("permission required")
|
||||
|| lower_content.contains("allow once or always?")
|
||||
|| lower_content.contains("asking user")
|
||||
|| lower_content.contains("enter your response")
|
||||
|| lower_content.contains("review your answers:")
|
||||
|| lower_content.contains("shell awaiting input")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -2544,6 +2647,158 @@ mod tests {
|
|||
assert_eq!(detect_state(Some(Agent::Hermes), screen), AgentState::Idle);
|
||||
}
|
||||
|
||||
// ---- Qodercli ----
|
||||
|
||||
#[test]
|
||||
fn qodercli_identified_by_process_name() {
|
||||
assert_eq!(identify_agent("qodercli"), Some(Agent::Qodercli));
|
||||
assert_eq!(identify_agent("qoderclicn"), Some(Agent::Qodercli));
|
||||
assert_eq!(identify_agent("qoder"), Some(Agent::Qodercli));
|
||||
assert_eq!(identify_agent("qodercn"), Some(Agent::Qodercli));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qodercli_blocked_on_confirmation() {
|
||||
assert_eq!(
|
||||
detect_qodercli("Waiting for user confirmation..."),
|
||||
AgentState::Blocked,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qodercli_working_on_spinner() {
|
||||
assert_eq!(detect_qodercli("\u{280B} Thinking..."), AgentState::Working);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qodercli_idle_on_prompt() {
|
||||
assert_eq!(detect_qodercli("> "), AgentState::Idle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qodercli_idle_when_only_stale_braille_glyph_in_scrollback() {
|
||||
// A single stray braille character in a previous output line must not
|
||||
// flip the pane to Working — only an actual spinner *row* should.
|
||||
let screen = "\
|
||||
agent finished a previous task.\n\
|
||||
\u{280B}\n\
|
||||
> \n";
|
||||
assert_eq!(detect_qodercli(screen), AgentState::Idle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qodercli_working_on_full_spinner_row() {
|
||||
// Real spinner row: braille glyph + space + alphabetic phrase.
|
||||
let screen = "\u{280B} Thinking...\n";
|
||||
assert_eq!(detect_qodercli(screen), AgentState::Working);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qodercli_working_on_esc_to_cancel_hint() {
|
||||
// The "(esc to cancel, …)" suffix is qodercli's explicit working
|
||||
// marker. It must trigger Working even if a hook icon replaced the
|
||||
// spinner glyph in this frame.
|
||||
let screen = "Thinking... (esc to cancel, 5s)\n";
|
||||
assert_eq!(detect_qodercli(screen), AgentState::Working);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qodercli_idle_when_text_mentions_working_in_prose() {
|
||||
// The previous heuristic treated the bare word "working" as Working,
|
||||
// which produced false positives for narrative output (commits, logs,
|
||||
// Markdown). The pane should remain Idle until a real working signal
|
||||
// appears.
|
||||
let screen = "\
|
||||
fix: keep working set warm across reloads\n\
|
||||
\n\
|
||||
> \n";
|
||||
assert_eq!(detect_qodercli(screen), AgentState::Idle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qodercli_idle_override_wins_over_spinner_row() {
|
||||
// While the user is holding Ctrl+C, qodercli flashes a "press again"
|
||||
// banner over the prompt. The pane is effectively idle there even if
|
||||
// a stale spinner row is still in the buffer.
|
||||
let screen = "\
|
||||
\u{280B} Thinking...\n\
|
||||
Press Ctrl+C again to exit.\n";
|
||||
assert_eq!(detect_qodercli(screen), AgentState::Idle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qodercli_idle_override_wins_over_esc_rewind() {
|
||||
let screen = "Press Esc again to rewind.\n";
|
||||
assert_eq!(detect_qodercli(screen), AgentState::Idle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qodercli_blocked_on_permission_required_dialog() {
|
||||
// qodercli renders this dialog when a tool call needs user approval.
|
||||
let screen = "\
|
||||
Permission Required\n\
|
||||
Caller: test\n\
|
||||
Command: mkdir -p /root/example\n\
|
||||
Allow once or always?\n\
|
||||
\u{276F} 1. Allow Once - allow `mkdir` for one\n\
|
||||
2. Always allow `mkdir` for future sessions\n\
|
||||
3. Reject and tell qodercli something\n";
|
||||
assert_eq!(detect_qodercli(screen), AgentState::Blocked);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qodercli_blocked_on_permission_required_alone() {
|
||||
// Even when the prompt copy gets truncated by the viewport, the title
|
||||
// alone should be enough to flip the pane to blocked.
|
||||
assert_eq!(detect_qodercli("Permission Required"), AgentState::Blocked,);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qodercli_blocked_on_askuser_enter_response_placeholder() {
|
||||
// qodercli's ask-user tool renders an input box with this placeholder
|
||||
// when waiting for the user to type a response.
|
||||
let screen = "\
|
||||
What kind of project are you working on?\n\
|
||||
> \n\
|
||||
Enter your response\n";
|
||||
assert_eq!(detect_qodercli(screen), AgentState::Blocked);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qodercli_blocked_on_askuser_review_tab() {
|
||||
// The multi-question/multi-select review tab heading is unique to the
|
||||
// ask-user dialog and means the agent is parked waiting on user input.
|
||||
let screen = "\
|
||||
Review your answers:\n\
|
||||
\n\
|
||||
Project type \u{2192} Web app\n\
|
||||
Stack \u{2192} (not answered)\n";
|
||||
assert_eq!(detect_qodercli(screen), AgentState::Blocked);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qodercli_blocked_on_interactive_shell_waiting() {
|
||||
// When qodercli spawns an interactive shell, the loading row turns
|
||||
// into a "Shell awaiting input" hint until the user takes focus.
|
||||
let screen = "! Shell awaiting input (Tab to focus)\n";
|
||||
assert_eq!(detect_qodercli(screen), AgentState::Blocked);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qodercli_blocked_on_askuser_single_choice_dialog() {
|
||||
// Single-select ask-user has no "Enter your response" placeholder and
|
||||
// no "Review your answers:" heading. The BaseTabDialog title
|
||||
// "Asking User" is the only stable signal across every ask-user form.
|
||||
let screen = "\
|
||||
Asking User\n\
|
||||
\n\
|
||||
Which framework should we use?\n\
|
||||
React\n\
|
||||
Vue\n\
|
||||
Svelte\n";
|
||||
assert_eq!(detect_qodercli(screen), AgentState::Blocked);
|
||||
}
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,114 @@
|
|||
#!/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=qodercli
|
||||
# HERDR_INTEGRATION_VERSION=1
|
||||
#
|
||||
# Reports qodercli agent state changes to herdr. Registered as a Command hook
|
||||
# in ~/.qoder/settings.json by `herdr integration install qodercli` and
|
||||
# invoked by qodercli's hook system on lifecycle events.
|
||||
#
|
||||
# qodercli (per https://docs.qoder.com/zh/cli/hooks) sends a JSON payload on
|
||||
# stdin describing the hook event. The event name is read from the stdin
|
||||
# payload's `hook_event_name` field, the same way
|
||||
# `assets/claude/herdr-agent-state.sh` already consumes claude code's stdin
|
||||
# payload. No environment variable is consulted for the event identity.
|
||||
|
||||
set -eu
|
||||
|
||||
action="${1:-}"
|
||||
hook_input_file="$(mktemp "${TMPDIR:-/tmp}/herdr-qodercli-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
|
||||
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" HERDR_HOOK_INPUT_FILE="$hook_input_file" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import socket
|
||||
import time
|
||||
|
||||
source = "herdr:qodercli"
|
||||
action = os.environ.get("HERDR_ACTION", "")
|
||||
pane_id = os.environ.get("HERDR_PANE_ID")
|
||||
socket_path = os.environ.get("HERDR_SOCKET_PATH")
|
||||
hook_input_file = os.environ.get("HERDR_HOOK_INPUT_FILE")
|
||||
|
||||
if not pane_id or not socket_path:
|
||||
raise SystemExit(0)
|
||||
|
||||
hook_input = {}
|
||||
if hook_input_file:
|
||||
try:
|
||||
with open(hook_input_file, encoding="utf-8") as handle:
|
||||
content = handle.read()
|
||||
if content.strip():
|
||||
hook_input = json.loads(content)
|
||||
except Exception:
|
||||
hook_input = {}
|
||||
|
||||
# Per docs.qoder.com/zh/cli/hooks the payload always carries `hook_event_name`.
|
||||
hook_event_name = str(hook_input.get("hook_event_name") or "")
|
||||
is_subagent = bool(hook_input.get("agent_id"))
|
||||
if hook_event_name == "SubagentStop":
|
||||
# SubagentStop is a completion event; never let it revive an idle pane the
|
||||
# way the parallel claude integration does.
|
||||
raise SystemExit(0)
|
||||
if is_subagent and action in ("idle", "release"):
|
||||
# Subagent completion must not make the parent pane look done early.
|
||||
raise SystemExit(0)
|
||||
|
||||
request_id = f"{source}:{int(time.time() * 1000)}:{random.randrange(1_000_000):06d}"
|
||||
report_seq = time.time_ns()
|
||||
session_id = hook_input.get("session_id")
|
||||
agent_session_id = session_id if isinstance(session_id, str) and session_id else None
|
||||
if action == "release":
|
||||
request = {
|
||||
"id": request_id,
|
||||
"method": "pane.release_agent",
|
||||
"params": {
|
||||
"pane_id": pane_id,
|
||||
"source": source,
|
||||
"agent": "qodercli",
|
||||
"seq": report_seq,
|
||||
},
|
||||
}
|
||||
else:
|
||||
request = {
|
||||
"id": request_id,
|
||||
"method": "pane.report_agent",
|
||||
"params": {
|
||||
"pane_id": pane_id,
|
||||
"source": source,
|
||||
"agent": "qodercli",
|
||||
"state": action,
|
||||
"seq": report_seq,
|
||||
},
|
||||
}
|
||||
if agent_session_id:
|
||||
request["params"]["agent_session_id"] = agent_session_id
|
||||
|
||||
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
|
||||
|
|
@ -34,6 +34,10 @@ const HERMES_PLUGIN_INIT_INSTALL_NAME: &str = "__init__.py";
|
|||
const HERMES_PLUGIN_MANIFEST_ASSET: &str = include_str!("assets/hermes/plugin.yaml");
|
||||
const HERMES_PLUGIN_INIT_ASSET: &str = include_str!("assets/hermes/__init__.py");
|
||||
const HERMES_INTEGRATION_VERSION: u32 = 2;
|
||||
const QODERCLI_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh";
|
||||
const QODERCLI_HOOK_ASSET: &str = include_str!("assets/qodercli/herdr-agent-state.sh");
|
||||
const QODERCLI_INTEGRATION_VERSION: u32 = 1;
|
||||
const QODERCLI_CONFIG_DIR_ENV_VAR: &str = "QODER_CONFIG_DIR";
|
||||
const INTEGRATION_VERSION_MARKER: &str = "HERDR_INTEGRATION_VERSION=";
|
||||
|
||||
#[derive(Debug)]
|
||||
|
|
@ -66,6 +70,20 @@ pub(crate) struct HermesInstallPaths {
|
|||
pub config_path: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct QodercliInstallPaths {
|
||||
pub hook_path: PathBuf,
|
||||
pub settings_path: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct QodercliUninstallResult {
|
||||
pub hook_path: PathBuf,
|
||||
pub settings_path: PathBuf,
|
||||
pub removed_hook_file: bool,
|
||||
pub updated_settings: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct IntegrationStatus {
|
||||
pub target: crate::api::schema::IntegrationTarget,
|
||||
|
|
@ -229,6 +247,19 @@ pub(crate) fn install_target(
|
|||
),
|
||||
]
|
||||
}
|
||||
crate::api::schema::IntegrationTarget::Qodercli => {
|
||||
let installed = install_qodercli()?;
|
||||
vec![
|
||||
format!(
|
||||
"installed qodercli integration hook to {}",
|
||||
installed.hook_path.display()
|
||||
),
|
||||
format!(
|
||||
"ensured qodercli settings at {}",
|
||||
installed.settings_path.display()
|
||||
),
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
crate::logging::integration_action("install", integration_target_label(target), "ok");
|
||||
|
|
@ -366,6 +397,33 @@ pub(crate) fn uninstall_target(
|
|||
}
|
||||
messages
|
||||
}
|
||||
crate::api::schema::IntegrationTarget::Qodercli => {
|
||||
let result = uninstall_qodercli()?;
|
||||
let mut messages = Vec::new();
|
||||
if result.removed_hook_file {
|
||||
messages.push(format!(
|
||||
"removed qodercli hook at {}",
|
||||
result.hook_path.display()
|
||||
));
|
||||
} else {
|
||||
messages.push(format!(
|
||||
"no qodercli hook found at {}",
|
||||
result.hook_path.display()
|
||||
));
|
||||
}
|
||||
if result.updated_settings {
|
||||
messages.push(format!(
|
||||
"removed herdr qodercli hook entries from {}",
|
||||
result.settings_path.display()
|
||||
));
|
||||
} else {
|
||||
messages.push(format!(
|
||||
"no herdr qodercli hook entries found in {}",
|
||||
result.settings_path.display()
|
||||
));
|
||||
}
|
||||
messages
|
||||
}
|
||||
};
|
||||
|
||||
crate::logging::integration_action("uninstall", integration_target_label(target), "ok");
|
||||
|
|
@ -382,6 +440,7 @@ pub(crate) fn integration_target_label(
|
|||
crate::api::schema::IntegrationTarget::Codex => "codex",
|
||||
crate::api::schema::IntegrationTarget::Opencode => "opencode",
|
||||
crate::api::schema::IntegrationTarget::Hermes => "hermes",
|
||||
crate::api::schema::IntegrationTarget::Qodercli => "qodercli",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -393,6 +452,7 @@ fn integration_target_command(target: crate::api::schema::IntegrationTarget) ->
|
|||
crate::api::schema::IntegrationTarget::Codex => "codex",
|
||||
crate::api::schema::IntegrationTarget::Opencode => "opencode",
|
||||
crate::api::schema::IntegrationTarget::Hermes => "hermes",
|
||||
crate::api::schema::IntegrationTarget::Qodercli => "qodercli",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -466,7 +526,7 @@ fn integration_specs() -> [(
|
|||
crate::api::schema::IntegrationTarget,
|
||||
io::Result<PathBuf>,
|
||||
u32,
|
||||
); 6] {
|
||||
); 7] {
|
||||
[
|
||||
(
|
||||
crate::api::schema::IntegrationTarget::Pi,
|
||||
|
|
@ -498,6 +558,11 @@ fn integration_specs() -> [(
|
|||
hermes_plugin_dir().map(|dir| dir.join(HERMES_PLUGIN_INIT_INSTALL_NAME)),
|
||||
HERMES_INTEGRATION_VERSION,
|
||||
),
|
||||
(
|
||||
crate::api::schema::IntegrationTarget::Qodercli,
|
||||
qodercli_dir().map(|dir| dir.join("hooks").join(QODERCLI_HOOK_INSTALL_NAME)),
|
||||
QODERCLI_INTEGRATION_VERSION,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
|
|
@ -1070,6 +1135,170 @@ pub(crate) fn uninstall_hermes() -> io::Result<HermesUninstallResult> {
|
|||
})
|
||||
}
|
||||
|
||||
pub(crate) fn install_qodercli() -> io::Result<QodercliInstallPaths> {
|
||||
let dir = qodercli_dir()?;
|
||||
if !dir.is_dir() {
|
||||
return Err(io::Error::other(format!(
|
||||
"qodercli config directory not found at {}. install qodercli first",
|
||||
dir.display()
|
||||
)));
|
||||
}
|
||||
|
||||
let hooks_dir = dir.join("hooks");
|
||||
fs::create_dir_all(&hooks_dir)?;
|
||||
|
||||
let hook_path = hooks_dir.join(QODERCLI_HOOK_INSTALL_NAME);
|
||||
fs::write(&hook_path, QODERCLI_HOOK_ASSET)?;
|
||||
make_executable(&hook_path)?;
|
||||
|
||||
// Register the hook in ~/.qoder/settings.json. The schema mirrors claude
|
||||
// settings.json (per https://docs.qoder.com/zh/cli/hooks): a top-level
|
||||
// `hooks` object keyed by event name, each entry holding a matcher + a
|
||||
// list of `{type: "command", command, timeout?}` invocations. The hook
|
||||
// script reads the event payload from stdin via `hook_event_name` so the
|
||||
// installation never depends on a `QODER_HOOK_EVENT` environment
|
||||
// variable.
|
||||
let settings_path = dir.join("settings.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,
|
||||
"qodercli settings",
|
||||
"qodercli settings hooks",
|
||||
)?;
|
||||
let quoted_hook_path = shell_single_quote(&hook_path.display().to_string());
|
||||
|
||||
// SubagentStop is intentionally *not* mapped to working: the hook script
|
||||
// returns early on it (mirroring assets/claude/herdr-agent-state.sh) so
|
||||
// that recap/away-summary frames cannot revive an idle pane.
|
||||
ensure_command_hook(
|
||||
hooks,
|
||||
"SessionStart",
|
||||
format!("bash {quoted_hook_path} idle"),
|
||||
10,
|
||||
Some("*"),
|
||||
)?;
|
||||
ensure_command_hook(
|
||||
hooks,
|
||||
"UserPromptSubmit",
|
||||
format!("bash {quoted_hook_path} working"),
|
||||
10,
|
||||
Some("*"),
|
||||
)?;
|
||||
ensure_command_hook(
|
||||
hooks,
|
||||
"PreToolUse",
|
||||
format!("bash {quoted_hook_path} working"),
|
||||
10,
|
||||
Some("*"),
|
||||
)?;
|
||||
ensure_command_hook(
|
||||
hooks,
|
||||
"PermissionRequest",
|
||||
format!("bash {quoted_hook_path} blocked"),
|
||||
10,
|
||||
Some("*"),
|
||||
)?;
|
||||
ensure_command_hook(
|
||||
hooks,
|
||||
"Stop",
|
||||
format!("bash {quoted_hook_path} idle"),
|
||||
10,
|
||||
Some("*"),
|
||||
)?;
|
||||
ensure_command_hook(
|
||||
hooks,
|
||||
"SessionEnd",
|
||||
format!("bash {quoted_hook_path} release"),
|
||||
10,
|
||||
Some("*"),
|
||||
)?;
|
||||
|
||||
fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?;
|
||||
|
||||
Ok(QodercliInstallPaths {
|
||||
hook_path,
|
||||
settings_path,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn uninstall_qodercli() -> io::Result<QodercliUninstallResult> {
|
||||
let hook_path = qodercli_dir()?
|
||||
.join("hooks")
|
||||
.join(QODERCLI_HOOK_INSTALL_NAME);
|
||||
let settings_path = qodercli_dir()?.join("settings.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,
|
||||
"qodercli settings",
|
||||
"qodercli settings hooks",
|
||||
)? {
|
||||
let quoted_hook_path = shell_single_quote(&hook_path.display().to_string());
|
||||
updated_settings |= remove_command_hook(
|
||||
hooks,
|
||||
"SessionStart",
|
||||
&format!("bash {quoted_hook_path} idle"),
|
||||
)?;
|
||||
updated_settings |= remove_command_hook(
|
||||
hooks,
|
||||
"UserPromptSubmit",
|
||||
&format!("bash {quoted_hook_path} working"),
|
||||
)?;
|
||||
updated_settings |= remove_command_hook(
|
||||
hooks,
|
||||
"PreToolUse",
|
||||
&format!("bash {quoted_hook_path} working"),
|
||||
)?;
|
||||
updated_settings |= remove_command_hook(
|
||||
hooks,
|
||||
"PermissionRequest",
|
||||
&format!("bash {quoted_hook_path} blocked"),
|
||||
)?;
|
||||
updated_settings |=
|
||||
remove_command_hook(hooks, "Stop", &format!("bash {quoted_hook_path} idle"))?;
|
||||
updated_settings |= remove_command_hook(
|
||||
hooks,
|
||||
"SessionEnd",
|
||||
&format!("bash {quoted_hook_path} release"),
|
||||
)?;
|
||||
}
|
||||
|
||||
if updated_settings {
|
||||
fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?;
|
||||
}
|
||||
}
|
||||
|
||||
let removed_hook_file = remove_file_if_exists(&hook_path)?;
|
||||
|
||||
Ok(QodercliUninstallResult {
|
||||
hook_path,
|
||||
settings_path,
|
||||
removed_hook_file,
|
||||
updated_settings,
|
||||
})
|
||||
}
|
||||
|
||||
fn ensure_hooks_object<'a>(
|
||||
settings: &'a mut Value,
|
||||
settings_path: &Path,
|
||||
|
|
@ -1528,6 +1757,10 @@ fn hermes_plugin_dir() -> io::Result<PathBuf> {
|
|||
.join(HERMES_PLUGIN_INSTALL_NAME))
|
||||
}
|
||||
|
||||
fn qodercli_dir() -> io::Result<PathBuf> {
|
||||
config_dir_from_env_or_home(QODERCLI_CONFIG_DIR_ENV_VAR, &[".qoder"])
|
||||
}
|
||||
|
||||
fn home_dir() -> io::Result<PathBuf> {
|
||||
std::env::var("HOME")
|
||||
.map(PathBuf::from)
|
||||
|
|
@ -1548,6 +1781,7 @@ mod tests {
|
|||
std::env::remove_var(PI_CODING_AGENT_DIR_ENV_VAR);
|
||||
std::env::remove_var(CLAUDE_CONFIG_DIR_ENV_VAR);
|
||||
std::env::remove_var(CODEX_HOME_ENV_VAR);
|
||||
std::env::remove_var(QODERCLI_CONFIG_DIR_ENV_VAR);
|
||||
}
|
||||
|
||||
fn unique_base() -> PathBuf {
|
||||
|
|
@ -2626,5 +2860,158 @@ mod tests {
|
|||
assert!(OPENCODE_PLUGIN_ASSET.contains("agent_session_id: sessionID"));
|
||||
assert!(HERMES_PLUGIN_INIT_ASSET.contains("session_id = _session_id(kwargs)"));
|
||||
assert!(HERMES_PLUGIN_INIT_ASSET.contains("agent_session_id"));
|
||||
// Qoder hook reads the event from the stdin JSON payload (per
|
||||
// https://docs.qoder.com/zh/cli/hooks). Make sure the bundled script
|
||||
// never reaches for a QODER_HOOK_EVENT environment variable.
|
||||
assert!(QODERCLI_HOOK_ASSET.contains("HERDR_HOOK_INPUT_FILE"));
|
||||
assert!(QODERCLI_HOOK_ASSET.contains("hook_event_name"));
|
||||
assert!(QODERCLI_HOOK_ASSET.contains("agent_session_id"));
|
||||
assert!(!QODERCLI_HOOK_ASSET.contains("QODER_HOOK_EVENT"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_qodercli_writes_hook_and_updates_settings() {
|
||||
let _lock = integration_env_lock();
|
||||
let base = unique_base();
|
||||
let qoder_dir = base.join(".qoder");
|
||||
fs::create_dir_all(&qoder_dir).unwrap();
|
||||
fs::write(
|
||||
qoder_dir.join("settings.json"),
|
||||
r#"{"permissions":{"allow":["Read"]},"hooks":{}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
std::env::set_var(QODERCLI_CONFIG_DIR_ENV_VAR, &qoder_dir);
|
||||
|
||||
let installed = install_qodercli().unwrap();
|
||||
|
||||
assert_eq!(
|
||||
installed.hook_path,
|
||||
qoder_dir.join("hooks").join(QODERCLI_HOOK_INSTALL_NAME)
|
||||
);
|
||||
assert_eq!(installed.settings_path, qoder_dir.join("settings.json"));
|
||||
assert!(installed.hook_path.is_file());
|
||||
|
||||
let settings: Value =
|
||||
serde_json::from_str(&fs::read_to_string(&installed.settings_path).unwrap()).unwrap();
|
||||
let hooks = settings
|
||||
.get("hooks")
|
||||
.and_then(Value::as_object)
|
||||
.expect("hooks should be present");
|
||||
for event in [
|
||||
"SessionStart",
|
||||
"UserPromptSubmit",
|
||||
"PreToolUse",
|
||||
"PermissionRequest",
|
||||
"Stop",
|
||||
"SessionEnd",
|
||||
] {
|
||||
assert!(
|
||||
hooks.contains_key(event),
|
||||
"expected hooks.{event} to be registered"
|
||||
);
|
||||
}
|
||||
// Pre-existing settings keys must be preserved.
|
||||
assert!(settings.get("permissions").is_some());
|
||||
|
||||
std::env::remove_var(QODERCLI_CONFIG_DIR_ENV_VAR);
|
||||
let _ = fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_qodercli_is_idempotent_for_hook_entries() {
|
||||
let _lock = integration_env_lock();
|
||||
let base = unique_base();
|
||||
let qoder_dir = base.join(".qoder");
|
||||
fs::create_dir_all(&qoder_dir).unwrap();
|
||||
std::env::set_var(QODERCLI_CONFIG_DIR_ENV_VAR, &qoder_dir);
|
||||
|
||||
install_qodercli().unwrap();
|
||||
install_qodercli().unwrap();
|
||||
|
||||
let settings: Value =
|
||||
serde_json::from_str(&fs::read_to_string(qoder_dir.join("settings.json")).unwrap())
|
||||
.unwrap();
|
||||
let hooks = settings.get("hooks").and_then(Value::as_object).unwrap();
|
||||
for event in [
|
||||
"SessionStart",
|
||||
"UserPromptSubmit",
|
||||
"PreToolUse",
|
||||
"PermissionRequest",
|
||||
"Stop",
|
||||
"SessionEnd",
|
||||
] {
|
||||
let entries = hooks.get(event).and_then(Value::as_array).unwrap();
|
||||
assert_eq!(
|
||||
entries.len(),
|
||||
1,
|
||||
"expected hooks.{event} to contain exactly one entry, got {entries:?}"
|
||||
);
|
||||
}
|
||||
|
||||
std::env::remove_var(QODERCLI_CONFIG_DIR_ENV_VAR);
|
||||
let _ = fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uninstall_qodercli_removes_herdr_hooks_and_preserves_others() {
|
||||
let _lock = integration_env_lock();
|
||||
let base = unique_base();
|
||||
let qoder_dir = base.join(".qoder");
|
||||
fs::create_dir_all(&qoder_dir).unwrap();
|
||||
std::env::set_var(QODERCLI_CONFIG_DIR_ENV_VAR, &qoder_dir);
|
||||
|
||||
install_qodercli().unwrap();
|
||||
// Inject a foreign hook entry the user might have configured by hand.
|
||||
let mut settings: Value =
|
||||
serde_json::from_str(&fs::read_to_string(qoder_dir.join("settings.json")).unwrap())
|
||||
.unwrap();
|
||||
settings["hooks"]["UserPromptSubmit"]
|
||||
.as_array_mut()
|
||||
.unwrap()
|
||||
.push(json!({
|
||||
"matcher": "*",
|
||||
"hooks": [{"type": "command", "command": "echo user-defined"}],
|
||||
}));
|
||||
fs::write(
|
||||
qoder_dir.join("settings.json"),
|
||||
serde_json::to_string_pretty(&settings).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let result = uninstall_qodercli().unwrap();
|
||||
assert!(result.removed_hook_file);
|
||||
assert!(result.updated_settings);
|
||||
|
||||
let settings: Value =
|
||||
serde_json::from_str(&fs::read_to_string(qoder_dir.join("settings.json")).unwrap())
|
||||
.unwrap();
|
||||
let hooks = settings.get("hooks").and_then(Value::as_object).unwrap();
|
||||
let remaining = hooks
|
||||
.get("UserPromptSubmit")
|
||||
.and_then(Value::as_array)
|
||||
.unwrap();
|
||||
assert_eq!(remaining.len(), 1);
|
||||
let cmd = remaining[0]["hooks"][0]["command"].as_str().unwrap();
|
||||
assert_eq!(cmd, "echo user-defined");
|
||||
|
||||
std::env::remove_var(QODERCLI_CONFIG_DIR_ENV_VAR);
|
||||
let _ = fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_qodercli_errors_when_config_dir_missing() {
|
||||
let _lock = integration_env_lock();
|
||||
let base = unique_base();
|
||||
let missing = base.join(".qoder");
|
||||
std::env::set_var(QODERCLI_CONFIG_DIR_ENV_VAR, &missing);
|
||||
|
||||
let err = install_qodercli().unwrap_err().to_string();
|
||||
assert!(
|
||||
err.contains("qodercli config directory not found"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
|
||||
std::env::remove_var(QODERCLI_CONFIG_DIR_ENV_VAR);
|
||||
let _ = fs::remove_dir_all(base);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue