aboutsummaryrefslogtreecommitdiff
path: root/src/integration/assets/copilot
diff options
context:
space:
mode:
authorLane <150401101+LaneBirmingham@users.noreply.github.com>2026-06-02 08:32:05 +1000
committerGitHub <noreply@github.com>2026-06-02 01:32:05 +0300
commitf7d315d494b6a3d583574012526d15c1ddc82bfe (patch)
tree327585c6cb27d478bbb1e4b0da782d9bbfd00901 /src/integration/assets/copilot
parent99e097c7d3cf76ae2d53bac5e7e2b3fd88b83591 (diff)
feat: add github copilot cli integration (#386)
* feat: add github copilot cli integration refs #232 * docs: restore 0.6.5 changelog and document copilot hook flow * test: cover copilot camelcase hook payloads --------- Co-authored-by: Ogulcan Celik <ogulcancelik@gmail.com>
Diffstat (limited to 'src/integration/assets/copilot')
-rw-r--r--src/integration/assets/copilot/herdr-agent-state.sh183
1 files changed, 183 insertions, 0 deletions
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