aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/next/CHANGELOG.md1
-rw-r--r--docs/next/README.md5
-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.mdx2
-rw-r--r--docs/next/website/src/content/docs/integrations.mdx22
-rw-r--r--docs/next/website/src/content/docs/session-state.mdx1
-rw-r--r--src/agent_resume.rs52
-rw-r--r--src/api/schema.rs1
-rw-r--r--src/cli/integration.rs11
-rw-r--r--src/integration/assets/copilot/herdr-agent-state.sh183
-rw-r--r--src/integration/mod.rs449
-rw-r--r--tests/cli_wrapper.rs171
13 files changed, 889 insertions, 12 deletions
diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md
index 37b315c8..718d8281 100644
--- a/docs/next/CHANGELOG.md
+++ b/docs/next/CHANGELOG.md
@@ -5,6 +5,7 @@
### Added
- Added `ui.right_click_passthrough_modifier` so a configured modifier such as `ctrl` can forward right-click hold and drag gestures to mouse-reporting pane apps while normal right-click still opens Herdr's pane menu. (#148)
- Added Kilo Code CLI automatic detection for idle, working, and blocked terminal states. (#270)
+- Added `herdr integration install copilot` for GitHub Copilot CLI hooks that report prompt, tool, post-approval progress, permission, `ask_user`, `exit_plan_mode`, idle, session-exit state, and session ids through Herdr's socket API. When `[session] resume_agents_on_restore = true` is enabled, Herdr can resume Copilot panes with `copilot --resume=<id>`.
### Changed
- Native agent session restore is now enabled by default for supported panes with current official integrations. Set `[session] resume_agents_on_restore = false` to disable it.
diff --git a/docs/next/README.md b/docs/next/README.md
index 9b1cfa2d..39b3cef8 100644
--- a/docs/next/README.md
+++ b/docs/next/README.md
@@ -168,13 +168,14 @@ for agents outside the built-in list, herdr still works as a terminal multiplexe
### direct integrations
-the built-in pi, omp, claude code, codex, opencode, hermes, and qodercli integrations forward semantic state to herdr over the socket api. install with:
+the built-in pi, omp, claude code, codex, github copilot cli, opencode, hermes, and qodercli integrations forward semantic state to herdr over the socket api. install with:
```bash
herdr integration install pi
herdr integration install omp
herdr integration install claude
herdr integration install codex
+herdr integration install copilot
herdr integration install opencode
herdr integration install hermes
herdr integration install qodercli
@@ -223,7 +224,7 @@ In-app settings cover theme, sound, and toast preferences. Herdr writes logs und
- [install](https://herdr.dev/docs/install/) — install, update, Homebrew, 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, opencode, hermes, qodercli integrations
+- [integrations](https://herdr.dev/docs/integrations/) — pi, omp, claude code, codex, github copilot 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 bc540198..57d18b0e 100644
--- a/docs/next/website/src/content/docs/agents.mdx
+++ b/docs/next/website/src/content/docs/agents.mdx
@@ -58,6 +58,7 @@ herdr integration install pi
herdr integration install omp
herdr integration install claude
herdr integration install codex
+herdr integration install copilot
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 ad837142..383107cf 100644
--- a/docs/next/website/src/content/docs/cli-reference.mdx
+++ b/docs/next/website/src/content/docs/cli-reference.mdx
@@ -207,6 +207,7 @@ herdr integration install pi
herdr integration install omp
herdr integration install claude
herdr integration install codex
+herdr integration install copilot
herdr integration install opencode
herdr integration install hermes
herdr integration install qodercli
@@ -214,6 +215,7 @@ herdr integration uninstall pi
herdr integration uninstall omp
herdr integration uninstall claude
herdr integration uninstall codex
+herdr integration uninstall copilot
herdr integration uninstall opencode
herdr integration uninstall hermes
herdr integration uninstall qodercli
diff --git a/docs/next/website/src/content/docs/configuration.mdx b/docs/next/website/src/content/docs/configuration.mdx
index 1a47a6c1..4074d791 100644
--- a/docs/next/website/src/content/docs/configuration.mdx
+++ b/docs/next/website/src/content/docs/configuration.mdx
@@ -380,7 +380,7 @@ Herdr can restart supported agent panes in their native conversation sessions af
resume_agents_on_restore = true
```
-This is enabled by default. Herdr only resumes panes that reported a native session reference through an official Herdr integration. Supported resume targets are Claude Code, Codex, Pi, Hermes Agent, and OpenCode. Unsupported, missing, invalid, duplicated, or stale session references restore as a normal shell in the saved pane directory.
+This is enabled by default. Herdr only resumes panes that reported a native session reference through an official Herdr integration. Supported resume targets are Claude Code, Codex, GitHub Copilot CLI, Pi, Hermes Agent, and OpenCode. Unsupported, missing, invalid, duplicated, or stale session references restore as a normal shell in the saved pane directory.
Session references are stored in the local Herdr session snapshot. They are not shown in normal pane, agent, status, or event output.
diff --git a/docs/next/website/src/content/docs/integrations.mdx b/docs/next/website/src/content/docs/integrations.mdx
index f1d7d83e..2cf87e12 100644
--- a/docs/next/website/src/content/docs/integrations.mdx
+++ b/docs/next/website/src/content/docs/integrations.mdx
@@ -1,6 +1,6 @@
---
title: Integrations
-description: Install Herdr integrations for Pi, OMP, Claude Code, Codex, OpenCode, Hermes Agent, and Qoder CLI.
+description: Install Herdr integrations for Pi, OMP, Claude Code, Codex, GitHub Copilot CLI, OpenCode, Hermes Agent, and Qoder CLI.
---
Herdr detects supported agents automatically. Integrations make that detection more precise by reporting semantic state directly to Herdr.
@@ -16,6 +16,7 @@ herdr integration install pi
herdr integration install omp
herdr integration install claude
herdr integration install codex
+herdr integration install copilot
herdr integration install opencode
herdr integration install hermes
herdr integration install qodercli
@@ -28,6 +29,7 @@ herdr integration uninstall pi
herdr integration uninstall omp
herdr integration uninstall claude
herdr integration uninstall codex
+herdr integration uninstall copilot
herdr integration uninstall opencode
herdr integration uninstall hermes
herdr integration uninstall qodercli
@@ -45,9 +47,9 @@ Herdr combines three signals:
Integrations enrich state reporting. They do not replace process detection.
-Some integrations also report native agent session references. Herdr uses official session references to resume Claude Code, Codex, Pi, Hermes Agent, and OpenCode panes after a Herdr server restart unless `[session] resume_agents_on_restore = false` disables it.
+Some integrations also 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 `4`, Codex version `4`, OpenCode version `2`, 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 `4`, Codex version `4`, GitHub Copilot CLI version `1`, OpenCode version `2`, or Hermes Agent version `2`. OMP integration version `2` reports agent state only. Check installed versions with `herdr integration status`.
## Pi
@@ -107,6 +109,20 @@ Codex state is reported through the same local socket API used by other integrat
Herdr uses `~/.codex` by default, or `CODEX_HOME` when set. The Codex config directory must already exist. Install writes `herdr-agent-state.sh`, updates `hooks.json`, and ensures `[features] hooks = true` in `config.toml`. It also removes the deprecated top-level `codex_hooks` flag when present. Uninstall removes Herdr entries from `hooks.json` and deletes the hook script, but leaves `config.toml` unchanged.
+## GitHub Copilot CLI
+
+Install the GitHub Copilot CLI hook:
+
+```bash
+herdr integration install copilot
+```
+
+Copilot state is reported through the same local socket API used by other integrations.
+
+Herdr uses `~/.copilot` by default, or `COPILOT_HOME` when set. The Copilot config directory must already exist. Install writes `hooks/herdr-agent-state.sh` and updates `settings.json` with Herdr hook entries. The hook maps prompt submission, tool use, post-tool progress after approvals, `ask_user` prompts, `exit_plan_mode` plan review prompts, permission notifications, agent idle notifications, turn stops, and session exits into Herdr state. Uninstall removes Herdr entries from `settings.json` and deletes the hook script.
+
+After Copilot emits a session-bearing event, Herdr can use the reported session id to resume the pane with `copilot --resume=<id>`.
+
## OpenCode
Install the OpenCode plugin:
diff --git a/docs/next/website/src/content/docs/session-state.mdx b/docs/next/website/src/content/docs/session-state.mdx
index bfb62ef5..0b919903 100644
--- a/docs/next/website/src/content/docs/session-state.mdx
+++ b/docs/next/website/src/content/docs/session-state.mdx
@@ -56,6 +56,7 @@ Native session restore requires these Herdr integration versions or newer:
| Pi | `2` | `pi --session <path-or-id>` |
| Claude Code | `4` | `claude --resume <id>` |
| Codex | `4` | `codex resume <id>` |
+| GitHub Copilot CLI | `1` | `copilot --resume=<id>` |
| OpenCode | `2` | `opencode --session <id>` |
| Hermes Agent | `2` | `hermes --resume <id>` |
diff --git a/src/agent_resume.rs b/src/agent_resume.rs
index 8f450088..269b74de 100644
--- a/src/agent_resume.rs
+++ b/src/agent_resume.rs
@@ -106,6 +106,9 @@ pub fn plan(source: &str, agent: &str, session_ref: &AgentSessionRef) -> Option<
("herdr:codex", "codex", AgentSessionRefKind::Id) => {
vec!["codex".into(), "resume".into(), session_ref.value.clone()]
}
+ ("herdr:copilot", "copilot", AgentSessionRefKind::Id) => {
+ vec!["copilot".into(), format!("--resume={}", session_ref.value)]
+ }
("herdr:pi", "pi", AgentSessionRefKind::Path | AgentSessionRefKind::Id) => {
vec!["pi".into(), "--session".into(), session_ref.value.clone()]
}
@@ -145,6 +148,7 @@ fn is_official_agent_source(source: &str, agent: &str) -> bool {
(source, agent),
("herdr:claude", "claude")
| ("herdr:codex", "codex")
+ | ("herdr:copilot", "copilot")
| ("herdr:pi", "pi")
| ("herdr:hermes", "hermes")
| ("herdr:opencode", "opencode")
@@ -190,6 +194,16 @@ mod tests {
);
assert_eq!(
plan(
+ "herdr:copilot",
+ "copilot",
+ &AgentSessionRef::id("copilot-session").unwrap()
+ )
+ .unwrap()
+ .argv,
+ vec!["copilot", "--resume=copilot-session"]
+ );
+ assert_eq!(
+ plan(
"herdr:pi",
"pi",
&AgentSessionRef::path("/tmp/pi-session.jsonl").unwrap()
@@ -261,13 +275,34 @@ mod tests {
Some("/tmp/claude-session".into())
)
.is_none());
+
+ let session_ref =
+ session_ref_from_report("herdr:copilot", "copilot", Some("copilot-id".into()), None)
+ .unwrap();
+ assert_eq!(session_ref.kind, AgentSessionRefKind::Id);
+ assert_eq!(session_ref.value, "copilot-id");
+ assert!(session_ref_from_report(
+ "herdr:copilot",
+ "copilot",
+ None,
+ Some("/tmp/copilot-session".into())
+ )
+ .is_none());
}
#[test]
fn ids_are_data_not_shell_text() {
let id = "abc; rm -rf /";
- let plan = plan("herdr:codex", "codex", &AgentSessionRef::id(id).unwrap()).unwrap();
- assert_eq!(plan.argv, vec!["codex", "resume", id]);
+ let codex_plan = plan("herdr:codex", "codex", &AgentSessionRef::id(id).unwrap()).unwrap();
+ assert_eq!(codex_plan.argv, vec!["codex", "resume", id]);
+
+ let copilot_plan = plan(
+ "herdr:copilot",
+ "copilot",
+ &AgentSessionRef::id(id).unwrap(),
+ )
+ .unwrap();
+ assert_eq!(copilot_plan.argv, vec!["copilot", "--resume=abc; rm -rf /"]);
}
#[test]
@@ -284,6 +319,12 @@ mod tests {
&AgentSessionRef::path("/tmp/opencode-session").unwrap()
)
.is_none());
+ assert!(plan(
+ "herdr:copilot",
+ "copilot",
+ &AgentSessionRef::path("/tmp/copilot-session").unwrap()
+ )
+ .is_none());
assert!(session_ref_from_snapshot(
"herdr:hermes",
"hermes",
@@ -298,5 +339,12 @@ mod tests {
"opencode-session"
)
.is_some());
+ assert!(session_ref_from_snapshot(
+ "herdr:copilot",
+ "copilot",
+ AgentSessionRefKind::Id,
+ "copilot-session"
+ )
+ .is_some());
}
}
diff --git a/src/api/schema.rs b/src/api/schema.rs
index 5d933af9..794f6db4 100644
--- a/src/api/schema.rs
+++ b/src/api/schema.rs
@@ -508,6 +508,7 @@ pub enum IntegrationTarget {
Omp,
Claude,
Codex,
+ Copilot,
Opencode,
Hermes,
Qodercli,
diff --git a/src/cli/integration.rs b/src/cli/integration.rs
index d347628c..d8eb027c 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|opencode|hermes|qodercli>"
+ "usage: herdr integration {action} <pi|omp|claude|codex|copilot|opencode|hermes|qodercli>"
);
return Ok(None);
};
if args.len() != 1 {
eprintln!(
- "usage: herdr integration {action} <pi|omp|claude|codex|opencode|hermes|qodercli>"
+ "usage: herdr integration {action} <pi|omp|claude|codex|copilot|opencode|hermes|qodercli>"
);
return Ok(None);
}
@@ -119,12 +119,15 @@ fn parse_integration_target(
"omp" => IntegrationTarget::Omp,
"claude" => IntegrationTarget::Claude,
"codex" => IntegrationTarget::Codex,
+ "copilot" => IntegrationTarget::Copilot,
"opencode" => IntegrationTarget::Opencode,
"hermes" => IntegrationTarget::Hermes,
"qodercli" => IntegrationTarget::Qodercli,
_ => {
eprintln!("unknown integration target: {target}");
- eprintln!("currently supported: pi, omp, claude, codex, opencode, hermes, qodercli");
+ eprintln!(
+ "currently supported: pi, omp, claude, codex, copilot, opencode, hermes, qodercli"
+ );
return Ok(None);
}
};
@@ -138,6 +141,7 @@ fn print_integration_help() {
eprintln!(" herdr integration install omp");
eprintln!(" herdr integration install claude");
eprintln!(" herdr integration install codex");
+ eprintln!(" herdr integration install copilot");
eprintln!(" herdr integration install opencode");
eprintln!(" herdr integration install hermes");
eprintln!(" herdr integration install qodercli");
@@ -145,6 +149,7 @@ fn print_integration_help() {
eprintln!(" herdr integration uninstall omp");
eprintln!(" herdr integration uninstall claude");
eprintln!(" herdr integration uninstall codex");
+ eprintln!(" herdr integration uninstall copilot");
eprintln!(" herdr integration uninstall opencode");
eprintln!(" herdr integration uninstall hermes");
eprintln!(" herdr integration uninstall qodercli");
diff --git a/src/integration/assets/copilot/herdr-agent-state.sh b/src/integration/assets/copilot/herdr-agent-state.sh
new file mode 100644
index 00000000..766b562a
--- /dev/null
+++ b/src/integration/assets/copilot/herdr-agent-state.sh
@@ -0,0 +1,183 @@
+#!/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.
+# High-level state machine:
+# - SessionStart reports working when Copilot includes an initial prompt, else idle.
+# - UserPromptSubmit reports working for the active turn.
+# - PreToolUse reports blocked for ask_user and exit_plan_mode, else working.
+# - notification permission_prompt / elicitation_dialog reports blocked.
+# - PostToolUse / PostToolUseFailure clears handled blockers back to working.
+# - agentStop / Stop with end_turn reports idle.
+# - SessionEnd only releases ownership for real exits such as user_exit / abort.
+# Official Copilot hooks reference:
+# https://docs.github.com/en/copilot/reference/hooks-reference
+# HERDR_INTEGRATION_ID=copilot
+# HERDR_INTEGRATION_VERSION=1
+
+set -eu
+
+hook_input_file="$(mktemp "${TMPDIR:-/tmp}/herdr-copilot-hook.XXXXXX")" || exit 0
+trap 'rm -f "$hook_input_file"' EXIT HUP INT TERM
+cat >"$hook_input_file" 2>/dev/null || true
+
+[ "${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 time
+
+source = "herdr:copilot"
+agent = "copilot"
+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():
+ parsed = json.loads(content)
+ if isinstance(parsed, dict):
+ hook_input = parsed
+ except Exception:
+ hook_input = {}
+
+def first_text(*keys):
+ for key in keys:
+ value = hook_input.get(key)
+ if isinstance(value, str) and value:
+ return value
+ return None
+
+def normalize_event(event):
+ return event.replace("_", "").replace("-", "").lower()
+
+def infer_event():
+ explicit = first_text("hook_event_name", "hookEventName")
+ if explicit:
+ return explicit
+ if first_text("notification_type", "notificationType"):
+ return "notification"
+ if "toolResult" in hook_input or "tool_result" in hook_input:
+ return "postToolUse"
+ if "error" in hook_input and first_text("tool_name", "toolName"):
+ return "postToolUseFailure"
+ if first_text("tool_name", "toolName"):
+ return "preToolUse"
+ if first_text("stop_reason", "stopReason"):
+ return "agentStop"
+ if first_text("reason"):
+ return "sessionEnd"
+ if "prompt" in hook_input:
+ return "userPromptSubmitted"
+ if (
+ "initial_prompt" in hook_input
+ or "initialPrompt" in hook_input
+ or "source" in hook_input
+ or first_text("session_id", "sessionId")
+ ):
+ return "sessionStart"
+ return ""
+
+def has_initial_prompt():
+ value = hook_input.get("initial_prompt", hook_input.get("initialPrompt"))
+ return isinstance(value, str) and bool(value.strip())
+
+def is_user_blocking_tool(name):
+ return name in ("ask_user", "exit_plan_mode")
+
+def is_ignored_post_tool(name):
+ # report_intent can arrive after ask_user has already blocked the turn but
+ # before the user answers. Suppress it so it cannot clear that blocker.
+ return name in ("report_intent",)
+
+def send(method, params):
+ request = {
+ "id": f"{source}:{int(time.time() * 1000)}:{random.randrange(1_000_000):06d}",
+ "method": method,
+ "params": {
+ "pane_id": pane_id,
+ "source": source,
+ "agent": agent,
+ "seq": time.time_ns(),
+ **params,
+ },
+ }
+ 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("utf-8"))
+ try:
+ client.recv(4096)
+ except Exception:
+ pass
+ client.close()
+ except Exception:
+ pass
+
+def report(state, session_id):
+ params = {"state": state}
+ if session_id:
+ params["agent_session_id"] = session_id
+ send("pane.report_agent", params)
+
+def release():
+ send("pane.release_agent", {})
+
+try:
+ event = infer_event()
+ event_key = normalize_event(event)
+ session_id = first_text("session_id", "sessionId")
+ tool_name = first_text("tool_name", "toolName")
+ notification_type = first_text("notification_type", "notificationType")
+ stop_reason = first_text("stop_reason", "stopReason")
+ reason = first_text("reason")
+
+ if event_key == "sessionstart":
+ report("working" if has_initial_prompt() else "idle", session_id)
+ elif event_key in ("userpromptsubmit", "userpromptsubmitted"):
+ report("working", session_id)
+ elif event_key == "pretooluse":
+ report("blocked" if is_user_blocking_tool(tool_name) else "working", session_id)
+ elif event_key in ("posttooluse", "posttoolusefailure"):
+ if is_ignored_post_tool(tool_name):
+ pass
+ else:
+ report("working", session_id)
+ elif event_key == "notification":
+ if notification_type in ("permission_prompt", "elicitation_dialog"):
+ report("blocked", session_id)
+ elif notification_type == "agent_idle":
+ report("idle", session_id)
+ else:
+ pass
+ elif event_key in ("stop", "agentstop", "sessionstop"):
+ if not stop_reason or stop_reason == "end_turn":
+ report("idle", session_id)
+ else:
+ pass
+ elif event_key == "sessionend":
+ # Copilot can emit reason=complete at normal turn boundaries. Keep
+ # ownership until a real session exit so Herdr can still track later turns.
+ if reason in ("user_exit", "abort"):
+ release()
+ else:
+ pass
+ else:
+ pass
+except Exception:
+ pass
+PY
diff --git a/src/integration/mod.rs b/src/integration/mod.rs
index ccd3e045..82afb195 100644
--- a/src/integration/mod.rs
+++ b/src/integration/mod.rs
@@ -25,6 +25,10 @@ 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 = 4;
const CODEX_HOME_ENV_VAR: &str = "CODEX_HOME";
+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;
+const COPILOT_HOME_ENV_VAR: &str = "COPILOT_HOME";
const OPENCODE_PLUGIN_INSTALL_NAME: &str = "herdr-agent-state.js";
const OPENCODE_PLUGIN_ASSET: &str = include_str!("assets/opencode/herdr-agent-state.js");
const OPENCODE_INTEGRATION_VERSION: u32 = 3;
@@ -54,6 +58,12 @@ pub(crate) struct CodexInstallPaths {
}
#[derive(Debug)]
+pub(crate) struct CopilotInstallPaths {
+ pub hook_path: PathBuf,
+ pub settings_path: PathBuf,
+}
+
+#[derive(Debug)]
pub(crate) struct OpenCodeInstallPaths {
pub plugin_path: PathBuf,
}
@@ -156,6 +166,14 @@ pub(crate) struct CodexUninstallResult {
}
#[derive(Debug)]
+pub(crate) struct CopilotUninstallResult {
+ pub hook_path: PathBuf,
+ pub settings_path: PathBuf,
+ pub removed_hook_file: bool,
+ pub updated_settings: bool,
+}
+
+#[derive(Debug)]
pub(crate) struct OpenCodeUninstallResult {
pub plugin_path: PathBuf,
pub removed_plugin: bool,
@@ -227,6 +245,19 @@ pub(crate) fn install_target(
),
]
}
+ crate::api::schema::IntegrationTarget::Copilot => {
+ let installed = install_copilot()?;
+ vec![
+ format!(
+ "installed copilot integration hook to {}",
+ installed.hook_path.display()
+ ),
+ format!(
+ "ensured copilot settings at {}",
+ installed.settings_path.display()
+ ),
+ ]
+ }
crate::api::schema::IntegrationTarget::Opencode => {
let installed = install_opencode()?;
vec![format!(
@@ -356,6 +387,33 @@ pub(crate) fn uninstall_target(
));
messages
}
+ crate::api::schema::IntegrationTarget::Copilot => {
+ let result = uninstall_copilot()?;
+ let mut messages = Vec::new();
+ if result.removed_hook_file {
+ messages.push(format!(
+ "removed copilot hook at {}",
+ result.hook_path.display()
+ ));
+ } else {
+ messages.push(format!(
+ "no copilot hook found at {}",
+ result.hook_path.display()
+ ));
+ }
+ if result.updated_settings {
+ messages.push(format!(
+ "removed herdr copilot hook entries from {}",
+ result.settings_path.display()
+ ));
+ } else {
+ messages.push(format!(
+ "no herdr copilot hook entries found in {}",
+ result.settings_path.display()
+ ));
+ }
+ messages
+ }
crate::api::schema::IntegrationTarget::Opencode => {
let result = uninstall_opencode()?;
if result.removed_plugin {
@@ -438,6 +496,7 @@ pub(crate) fn integration_target_label(
crate::api::schema::IntegrationTarget::Omp => "omp",
crate::api::schema::IntegrationTarget::Claude => "claude",
crate::api::schema::IntegrationTarget::Codex => "codex",
+ crate::api::schema::IntegrationTarget::Copilot => "copilot",
crate::api::schema::IntegrationTarget::Opencode => "opencode",
crate::api::schema::IntegrationTarget::Hermes => "hermes",
crate::api::schema::IntegrationTarget::Qodercli => "qodercli",
@@ -450,6 +509,7 @@ fn integration_target_command(target: crate::api::schema::IntegrationTarget) ->
crate::api::schema::IntegrationTarget::Omp => "omp",
crate::api::schema::IntegrationTarget::Claude => "claude",
crate::api::schema::IntegrationTarget::Codex => "codex",
+ crate::api::schema::IntegrationTarget::Copilot => "copilot",
crate::api::schema::IntegrationTarget::Opencode => "opencode",
crate::api::schema::IntegrationTarget::Hermes => "hermes",
crate::api::schema::IntegrationTarget::Qodercli => "qodercli",
@@ -526,7 +586,7 @@ fn integration_specs() -> [(
crate::api::schema::IntegrationTarget,
io::Result<PathBuf>,
u32,
-); 7] {
+); 8] {
[
(
crate::api::schema::IntegrationTarget::Pi,
@@ -549,6 +609,11 @@ fn integration_specs() -> [(
CODEX_INTEGRATION_VERSION,
),
(
+ crate::api::schema::IntegrationTarget::Copilot,
+ copilot_dir().map(|dir| dir.join("hooks").join(COPILOT_HOOK_INSTALL_NAME)),
+ COPILOT_INTEGRATION_VERSION,
+ ),
+ (
crate::api::schema::IntegrationTarget::Opencode,
opencode_dir().map(|dir| dir.join("plugins").join(OPENCODE_PLUGIN_INSTALL_NAME)),
OPENCODE_INTEGRATION_VERSION,
@@ -884,6 +949,68 @@ pub(crate) fn install_codex() -> io::Result<CodexInstallPaths> {
})
}
+pub(crate) fn install_copilot() -> io::Result<CopilotInstallPaths> {
+ let dir = copilot_dir()?;
+ if !dir.is_dir() {
+ return Err(io::Error::other(format!(
+ "copilot config directory not found at {}. install github copilot cli first",
+ dir.display()
+ )));
+ }
+
+ let hooks_dir = dir.join("hooks");
+ fs::create_dir_all(&hooks_dir)?;
+
+ let hook_path = hooks_dir.join(COPILOT_HOOK_INSTALL_NAME);
+ fs::write(&hook_path, COPILOT_HOOK_ASSET)?;
+ make_executable(&hook_path)?;
+
+ 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,
+ "copilot settings",
+ "copilot settings hooks",
+ )?;
+ let command = format!(
+ "bash {}",
+ shell_single_quote(&hook_path.display().to_string())
+ );
+ ensure_direct_command_hook(hooks, "SessionStart", command.clone(), 10, None)?;
+ ensure_direct_command_hook(hooks, "UserPromptSubmit", command.clone(), 10, None)?;
+ ensure_direct_command_hook(hooks, "PreToolUse", command.clone(), 10, None)?;
+ ensure_direct_command_hook(hooks, "PostToolUse", command.clone(), 10, None)?;
+ ensure_direct_command_hook(hooks, "PostToolUseFailure", command.clone(), 10, None)?;
+ ensure_direct_command_hook(hooks, "Stop", command.clone(), 10, None)?;
+ ensure_direct_command_hook(hooks, "agentStop", command.clone(), 10, None)?;
+ ensure_direct_command_hook(hooks, "SessionEnd", command.clone(), 10, None)?;
+ ensure_direct_command_hook(
+ hooks,
+ "notification",
+ command,
+ 10,
+ Some("permission_prompt|elicitation_dialog|agent_idle"),
+ )?;
+
+ fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?;
+
+ Ok(CopilotInstallPaths {
+ hook_path,
+ settings_path,
+ })
+}
+
pub(crate) fn install_opencode() -> io::Result<OpenCodeInstallPaths> {
let dir = opencode_dir()?;
if !dir.is_dir() {
@@ -1099,6 +1226,57 @@ pub(crate) fn uninstall_codex() -> io::Result<CodexUninstallResult> {
})
}
+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);
+ let settings_path = copilot_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,
+ "copilot settings",
+ "copilot settings hooks",
+ )? {
+ let command = format!(
+ "bash {}",
+ shell_single_quote(&hook_path.display().to_string())
+ );
+ updated_settings |= remove_direct_command_hook(hooks, "SessionStart", &command)?;
+ updated_settings |= remove_direct_command_hook(hooks, "UserPromptSubmit", &command)?;
+ updated_settings |= remove_direct_command_hook(hooks, "PreToolUse", &command)?;
+ updated_settings |= remove_direct_command_hook(hooks, "PostToolUse", &command)?;
+ updated_settings |= remove_direct_command_hook(hooks, "PostToolUseFailure", &command)?;
+ updated_settings |= remove_direct_command_hook(hooks, "Stop", &command)?;
+ updated_settings |= remove_direct_command_hook(hooks, "agentStop", &command)?;
+ updated_settings |= remove_direct_command_hook(hooks, "SessionEnd", &command)?;
+ updated_settings |= remove_direct_command_hook(hooks, "notification", &command)?;
+ }
+
+ if updated_settings {
+ fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?;
+ }
+ }
+
+ let removed_hook_file = remove_file_if_exists(&hook_path)?;
+
+ Ok(CopilotUninstallResult {
+ hook_path,
+ settings_path,
+ removed_hook_file,
+ updated_settings,
+ })
+}
+
pub(crate) fn uninstall_opencode() -> io::Result<OpenCodeUninstallResult> {
let plugin_path = opencode_dir()?
.join("plugins")
@@ -1393,6 +1571,55 @@ fn ensure_command_hook(
Ok(())
}
+// Claude and Codex use nested hook groups:
+// { "matcher": "...", "hooks": [{ "type": "command", ... }] }
+// Copilot and Qoder CLI use the flatter settings shape:
+// { "type": "command", "matcher": "...", "command": "...", ... }
+// Keep the helpers separate so install/uninstall preserves unrelated hooks in
+// each agent's native format instead of normalizing user configuration.
+fn ensure_direct_command_hook(
+ hooks: &mut Map<String, Value>,
+ event: &str,
+ command: String,
+ timeout_sec: u64,
+ matcher: Option<&str>,
+) -> io::Result<()> {
+ let entries = hooks
+ .entry(event.to_string())
+ .or_insert_with(|| Value::Array(Vec::new()))
+ .as_array_mut()
+ .ok_or_else(|| io::Error::other(format!("hook entries for {event} must be an array")))?;
+
+ if let Some(entry) = entries.iter_mut().find(|entry| {
+ entry.get("type").and_then(Value::as_str) == Some("command")
+ && entry.get("command").and_then(Value::as_str) == Some(command.as_str())
+ }) {
+ let Some(entry_object) = entry.as_object_mut() else {
+ return Ok(());
+ };
+ entry_object.insert("timeoutSec".to_string(), Value::Number(timeout_sec.into()));
+ match matcher {
+ Some(matcher) => {
+ entry_object.insert("matcher".to_string(), Value::String(matcher.to_string()));
+ }
+ None => {
+ entry_object.remove("matcher");
+ }
+ }
+ return Ok(());
+ }
+
+ let mut entry = Map::new();
+ entry.insert("type".to_string(), Value::String("command".to_string()));
+ if let Some(matcher) = matcher {
+ entry.insert("matcher".to_string(), Value::String(matcher.to_string()));
+ }
+ entry.insert("command".to_string(), Value::String(command));
+ entry.insert("timeoutSec".to_string(), Value::Number(timeout_sec.into()));
+ entries.push(Value::Object(entry));
+ Ok(())
+}
+
fn remove_command_hook(
hooks: &mut Map<String, Value>,
event: &str,
@@ -1435,6 +1662,31 @@ fn remove_command_hook(
Ok(removed)
}
+fn remove_direct_command_hook(
+ hooks: &mut Map<String, Value>,
+ event: &str,
+ command: &str,
+) -> io::Result<bool> {
+ let Some(entries_value) = hooks.get_mut(event) else {
+ return Ok(false);
+ };
+
+ let entries = entries_value
+ .as_array_mut()
+ .ok_or_else(|| io::Error::other(format!("hook entries for {event} must be an array")))?;
+
+ let before = entries.len();
+ entries.retain(|entry| {
+ !(entry.get("type").and_then(Value::as_str) == Some("command")
+ && entry.get("command").and_then(Value::as_str) == Some(command))
+ });
+ let removed = entries.len() != before;
+ if entries.is_empty() {
+ hooks.remove(event);
+ }
+ Ok(removed)
+}
+
fn is_matching_command_hook(hook: &Value, command: &str) -> bool {
hook.get("type").and_then(Value::as_str) == Some("command")
&& hook.get("command").and_then(Value::as_str) == Some(command)
@@ -1708,6 +1960,10 @@ fn codex_dir() -> io::Result<PathBuf> {
config_dir_from_env_or_home(CODEX_HOME_ENV_VAR, &[".codex"])
}
+fn copilot_dir() -> io::Result<PathBuf> {
+ config_dir_from_env_or_home(COPILOT_HOME_ENV_VAR, &[".copilot"])
+}
+
fn config_dir_from_env_or_home(
env_var: &str,
home_relative_segments: &[&str],
@@ -1781,6 +2037,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(COPILOT_HOME_ENV_VAR);
std::env::remove_var(QODERCLI_CONFIG_DIR_ENV_VAR);
}
@@ -2680,6 +2937,192 @@ mod tests {
}
#[test]
+ fn install_copilot_writes_hook_and_updates_settings() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let copilot_dir = home.join(".copilot");
+ fs::create_dir_all(&copilot_dir).unwrap();
+ fs::write(
+ copilot_dir.join("settings.json"),
+ r#"{"theme":"dark","hooks":{"PreToolUse":[{"type":"command","command":"echo keep","timeoutSec":10}]}}"#,
+ )
+ .unwrap();
+ std::env::set_var("HOME", &home);
+
+ let installed = install_copilot().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,
+ copilot_dir.join("hooks").join(COPILOT_HOOK_INSTALL_NAME)
+ );
+ assert_eq!(installed.settings_path, copilot_dir.join("settings.json"));
+ assert_eq!(hook_content, COPILOT_HOOK_ASSET);
+ assert_eq!(settings["theme"], "dark");
+ assert_eq!(settings["hooks"]["PreToolUse"].as_array().unwrap().len(), 2);
+ assert_eq!(settings["hooks"]["PreToolUse"][0]["command"], "echo keep");
+ assert!(settings["hooks"]["PreToolUse"][1]["command"]
+ .as_str()
+ .unwrap()
+ .contains(COPILOT_HOOK_INSTALL_NAME));
+ assert!(settings["hooks"]["PostToolUse"][0].get("matcher").is_none());
+ assert!(settings["hooks"]["PostToolUseFailure"][0]
+ .get("matcher")
+ .is_none());
+ assert_eq!(
+ settings["hooks"]["notification"][0]["matcher"],
+ "permission_prompt|elicitation_dialog|agent_idle"
+ );
+ assert!(settings["hooks"]["Stop"][0]["command"]
+ .as_str()
+ .unwrap()
+ .contains(COPILOT_HOOK_INSTALL_NAME));
+ assert!(settings["hooks"]["agentStop"][0]["command"]
+ .as_str()
+ .unwrap()
+ .contains(COPILOT_HOOK_INSTALL_NAME));
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+ }
+
+ #[test]
+ fn copilot_v1_integration_status_is_current() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let copilot_hooks_dir = home.join(".copilot").join("hooks");
+ fs::create_dir_all(&copilot_hooks_dir).unwrap();
+ let hook_path = copilot_hooks_dir.join(COPILOT_HOOK_INSTALL_NAME);
+ fs::write(
+ &hook_path,
+ "#!/bin/sh\n# HERDR_INTEGRATION_ID=copilot\n# HERDR_INTEGRATION_VERSION=1\n",
+ )
+ .unwrap();
+ std::env::set_var("HOME", &home);
+
+ let statuses = installed_integration_statuses();
+ let copilot = statuses
+ .iter()
+ .find(|status| status.target == crate::api::schema::IntegrationTarget::Copilot)
+ .unwrap();
+
+ assert_eq!(copilot.path, hook_path);
+ assert_eq!(copilot.installed_version, Some(1));
+ assert_eq!(copilot.expected_version, 1);
+ assert_eq!(copilot.state, IntegrationStatusKind::Current);
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+ }
+
+ #[test]
+ fn install_copilot_uses_copilot_home_env_and_is_idempotent() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let copilot_dir = base.join("custom-copilot");
+ fs::create_dir_all(&copilot_dir).unwrap();
+ std::env::set_var(COPILOT_HOME_ENV_VAR, &copilot_dir);
+
+ let installed = install_copilot().unwrap();
+ install_copilot().unwrap();
+
+ let settings: Value =
+ serde_json::from_str(&fs::read_to_string(copilot_dir.join("settings.json")).unwrap())
+ .unwrap();
+
+ assert_eq!(
+ installed.hook_path,
+ copilot_dir.join("hooks").join(COPILOT_HOOK_INSTALL_NAME)
+ );
+ assert_eq!(
+ settings["hooks"]["SessionStart"].as_array().unwrap().len(),
+ 1
+ );
+ assert_eq!(settings["hooks"]["PreToolUse"].as_array().unwrap().len(), 1);
+ assert_eq!(
+ settings["hooks"]["PostToolUse"].as_array().unwrap().len(),
+ 1
+ );
+ assert_eq!(
+ settings["hooks"]["PostToolUseFailure"]
+ .as_array()
+ .unwrap()
+ .len(),
+ 1
+ );
+ assert_eq!(
+ settings["hooks"]["notification"].as_array().unwrap().len(),
+ 1
+ );
+
+ clear_integration_path_env();
+ let _ = fs::remove_dir_all(base);
+ }
+
+ #[test]
+ fn uninstall_copilot_removes_herdr_hooks_and_preserves_others() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let copilot_dir = home.join(".copilot");
+ let hooks_dir = copilot_dir.join("hooks");
+ fs::create_dir_all(&hooks_dir).unwrap();
+ let hook_path = hooks_dir.join(COPILOT_HOOK_INSTALL_NAME);
+ fs::write(&hook_path, COPILOT_HOOK_ASSET).unwrap();
+ let command = format!(
+ "bash {}",
+ shell_single_quote(&hook_path.display().to_string())
+ );
+ fs::write(
+ copilot_dir.join("settings.json"),
+ format!(
+ r#"{{"hooks":{{"PreToolUse":[{{"type":"command","command":"{}","timeoutSec":10}},{{"type":"command","command":"echo keep","timeoutSec":10}}],"PostToolUse":[{{"type":"command","command":"{}","timeoutSec":10}}],"notification":[{{"type":"command","matcher":"permission_prompt|elicitation_dialog|agent_idle","command":"{}","timeoutSec":10}}]}}}}"#,
+ command,
+ command,
+ command,
+ ),
+ )
+ .unwrap();
+ std::env::set_var("HOME", &home);
+
+ let result = uninstall_copilot().unwrap();
+ let settings: Value =
+ serde_json::from_str(&fs::read_to_string(copilot_dir.join("settings.json")).unwrap())
+ .unwrap();
+
+ assert!(result.removed_hook_file);
+ assert!(result.updated_settings);
+ assert!(!result.hook_path.exists());
+ assert_eq!(settings["hooks"]["PreToolUse"].as_array().unwrap().len(), 1);
+ assert_eq!(settings["hooks"]["PreToolUse"][0]["command"], "echo keep");
+ assert!(settings["hooks"].get("PostToolUse").is_none());
+ assert!(settings["hooks"].get("notification").is_none());
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+ }
+
+ #[test]
+ fn install_copilot_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_copilot().unwrap_err().to_string();
+
+ assert!(err.contains("copilot config directory not found"));
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+ }
+
+ #[test]
fn install_opencode_writes_plugin_to_plugins_dir() {
let _lock = integration_env_lock();
let base = unique_base();
@@ -2856,6 +3299,10 @@ mod tests {
assert!(CLAUDE_HOOK_ASSET.contains("agent_session_id"));
assert!(CODEX_HOOK_ASSET.contains("HERDR_HOOK_INPUT_FILE"));
assert!(CODEX_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"));
+ assert!(COPILOT_HOOK_ASSET.contains("exit_plan_mode"));
assert!(OPENCODE_PLUGIN_ASSET.contains("properties?.sessionID"));
assert!(OPENCODE_PLUGIN_ASSET.contains("dispose: async"));
assert!(OPENCODE_PLUGIN_ASSET.contains("agent_session_id: sessionID"));
diff --git a/tests/cli_wrapper.rs b/tests/cli_wrapper.rs
index 0c81e7e4..511674ab 100644
--- a/tests/cli_wrapper.rs
+++ b/tests/cli_wrapper.rs
@@ -500,6 +500,14 @@ fn run_codex_hook(action: &str, hook_input: &str) -> Option<serde_json::Value> {
)
}
+fn run_copilot_hook(hook_input: &str) -> Option<serde_json::Value> {
+ run_shell_hook(
+ "src/integration/assets/copilot/herdr-agent-state.sh",
+ &[],
+ hook_input,
+ )
+}
+
fn run_shell_hook(asset_path: &str, args: &[&str], hook_input: &str) -> Option<serde_json::Value> {
let base = unique_test_dir();
fs::create_dir_all(&base).unwrap();
@@ -623,6 +631,169 @@ fn codex_hook_reports_session_id_from_stdin() {
}
#[test]
+fn copilot_hook_maps_turn_lifecycle() {
+ let session_start_idle = run_copilot_hook(
+ r#"{"hook_event_name":"SessionStart","session_id":"copilot-session","source":"resume"}"#,
+ )
+ .expect("copilot session start without prompt should report idle");
+ assert_eq!(session_start_idle["method"], "pane.report_agent");
+ assert_eq!(session_start_idle["params"]["state"], "idle");
+ assert_eq!(
+ session_start_idle["params"]["agent_session_id"],
+ "copilot-session"
+ );
+
+ let session_start_working = run_copilot_hook(
+ r#"{"hook_event_name":"SessionStart","sessionId":"copilot-session","initialPrompt":"run tests"}"#,
+ )
+ .expect("copilot session start with prompt should report working");
+ assert_eq!(session_start_working["method"], "pane.report_agent");
+ assert_eq!(session_start_working["params"]["state"], "working");
+ assert_eq!(
+ session_start_working["params"]["agent_session_id"],
+ "copilot-session"
+ );
+
+ let working = run_copilot_hook(
+ r#"{"hook_event_name":"UserPromptSubmit","session_id":"copilot-session","prompt":"run tests"}"#,
+ )
+ .expect("copilot prompt should report working");
+ assert_eq!(working["method"], "pane.report_agent");
+ assert_eq!(working["params"]["agent"], "copilot");
+ assert_eq!(working["params"]["state"], "working");
+ assert_eq!(working["params"]["agent_session_id"], "copilot-session");
+
+ let idle = run_copilot_hook(
+ r#"{"hook_event_name":"agentStop","session_id":"copilot-session","stop_reason":"end_turn"}"#,
+ )
+ .expect("copilot stop should report idle");
+ assert_eq!(idle["method"], "pane.report_agent");
+ assert_eq!(idle["params"]["state"], "idle");
+}
+
+#[test]
+fn copilot_hook_maps_camel_case_payloads() {
+ let session_start = run_copilot_hook(
+ r#"{"sessionId":"copilot-camel-session","source":"new","initialPrompt":"run tests"}"#,
+ )
+ .expect("copilot camelCase session start should report working");
+ assert_eq!(session_start["method"], "pane.report_agent");
+ assert_eq!(session_start["params"]["state"], "working");
+ assert_eq!(
+ session_start["params"]["agent_session_id"],
+ "copilot-camel-session"
+ );
+
+ let prompt =
+ run_copilot_hook(r#"{"sessionId":"copilot-camel-session","prompt":"continue the task"}"#)
+ .expect("copilot camelCase prompt should report working");
+ assert_eq!(prompt["method"], "pane.report_agent");
+ assert_eq!(prompt["params"]["state"], "working");
+
+ let blocker = run_copilot_hook(
+ r#"{"sessionId":"copilot-camel-session","toolName":"ask_user","toolArgs":{}}"#,
+ )
+ .expect("copilot camelCase ask_user should report blocked");
+ assert_eq!(blocker["method"], "pane.report_agent");
+ assert_eq!(blocker["params"]["state"], "blocked");
+
+ let answered = run_copilot_hook(
+ r#"{"sessionId":"copilot-camel-session","toolName":"ask_user","toolArgs":{},"toolResult":{"resultType":"success","textResultForLlm":"ok"}}"#,
+ )
+ .expect("copilot camelCase postToolUse should report working");
+ assert_eq!(answered["method"], "pane.report_agent");
+ assert_eq!(answered["params"]["state"], "working");
+
+ let idle = run_copilot_hook(
+ r#"{"sessionId":"copilot-camel-session","stopReason":"end_turn","transcriptPath":"/tmp/transcript.jsonl"}"#,
+ )
+ .expect("copilot camelCase agentStop should report idle");
+ assert_eq!(idle["method"], "pane.report_agent");
+ assert_eq!(idle["params"]["state"], "idle");
+
+ let user_exit =
+ run_copilot_hook(r#"{"sessionId":"copilot-camel-session","reason":"user_exit"}"#)
+ .expect("copilot camelCase user exit should release");
+ assert_eq!(user_exit["method"], "pane.release_agent");
+ assert_eq!(user_exit["params"]["agent"], "copilot");
+}
+
+#[test]
+fn copilot_hook_maps_user_prompts_and_notifications() {
+ let ask_user = run_copilot_hook(
+ r#"{"hook_event_name":"PreToolUse","session_id":"copilot-session","tool_name":"ask_user"}"#,
+ )
+ .expect("copilot ask_user should report blocked");
+ assert_eq!(ask_user["method"], "pane.report_agent");
+ assert_eq!(ask_user["params"]["state"], "blocked");
+
+ let stale_report_intent = run_copilot_hook(
+ r#"{"hook_event_name":"PostToolUse","session_id":"copilot-session","tool_name":"report_intent","tool_result":{"text_result_for_llm":"ok"}}"#,
+ );
+ assert!(
+ stale_report_intent.is_none(),
+ "postToolUse report_intent must not clear a pending ask_user prompt"
+ );
+
+ let ask_user_answered = run_copilot_hook(
+ r#"{"hook_event_name":"PostToolUse","session_id":"copilot-session","tool_name":"ask_user","tool_result":{"text_result_for_llm":"Blue"}}"#,
+ )
+ .expect("copilot answered ask_user should report working");
+ assert_eq!(ask_user_answered["method"], "pane.report_agent");
+ assert_eq!(ask_user_answered["params"]["state"], "working");
+
+ let exit_plan_mode = run_copilot_hook(
+ r#"{"hook_event_name":"PreToolUse","session_id":"copilot-session","tool_name":"exit_plan_mode"}"#,
+ )
+ .expect("copilot exit_plan_mode should report blocked");
+ assert_eq!(exit_plan_mode["method"], "pane.report_agent");
+ assert_eq!(exit_plan_mode["params"]["state"], "blocked");
+
+ let exit_plan_answered = run_copilot_hook(
+ r#"{"hook_event_name":"PostToolUse","session_id":"copilot-session","tool_name":"exit_plan_mode","tool_result":{"text_result_for_llm":"Approved"}}"#,
+ )
+ .expect("copilot answered exit_plan_mode should report working");
+ assert_eq!(exit_plan_answered["method"], "pane.report_agent");
+ assert_eq!(exit_plan_answered["params"]["state"], "working");
+
+ let notification = run_copilot_hook(
+ r#"{"hook_event_name":"notification","session_id":"copilot-session","notification_type":"permission_prompt"}"#,
+ )
+ .expect("copilot permission notification should report blocked");
+ assert_eq!(notification["method"], "pane.report_agent");
+ assert_eq!(notification["params"]["state"], "blocked");
+
+ let permission_approved_tool = run_copilot_hook(
+ r#"{"hook_event_name":"PostToolUse","session_id":"copilot-session","tool_name":"bash","tool_result":{"text_result_for_llm":"ok"}}"#,
+ )
+ .expect("copilot completed tool should report working after permission prompt");
+ assert_eq!(permission_approved_tool["method"], "pane.report_agent");
+ assert_eq!(permission_approved_tool["params"]["state"], "working");
+
+ let agent_idle = run_copilot_hook(
+ r#"{"hook_event_name":"notification","session_id":"copilot-session","notification_type":"agent_idle"}"#,
+ )
+ .expect("copilot agent_idle notification should report idle");
+ assert_eq!(agent_idle["method"], "pane.report_agent");
+ assert_eq!(agent_idle["params"]["state"], "idle");
+}
+
+#[test]
+fn copilot_hook_releases_on_user_exit_only() {
+ let complete = run_copilot_hook(
+ r#"{"hook_event_name":"SessionEnd","session_id":"copilot-session","reason":"complete"}"#,
+ );
+ assert!(complete.is_none());
+
+ let user_exit = run_copilot_hook(
+ r#"{"hook_event_name":"SessionEnd","session_id":"copilot-session","reason":"user_exit"}"#,
+ )
+ .expect("copilot user exit should release");
+ assert_eq!(user_exit["method"], "pane.release_agent");
+ assert_eq!(user_exit["params"]["agent"], "copilot");
+}
+
+#[test]
fn pane_run_sends_one_send_input_request_with_enter_key() {
let base = unique_test_dir();
fs::create_dir_all(&base).unwrap();