summaryrefslogtreecommitdiff
path: root/agent-id.py
diff options
context:
space:
mode:
Diffstat (limited to 'agent-id.py')
-rw-r--r--agent-id.py208
1 files changed, 208 insertions, 0 deletions
diff --git a/agent-id.py b/agent-id.py
new file mode 100644
index 0000000..00473f7
--- /dev/null
+++ b/agent-id.py
@@ -0,0 +1,208 @@
+#!/usr/bin/env python3
+"""agent-id — detect which agent harness called this tool.
+
+Walks /proc/self/.. up the process tree, inspecting cmdlines, comm names,
+and ancestor environment variables to identify Claude Code, Codex CLI,
+or Hermes/pi agent — without the caller passing any flags or env vars.
+
+Outputs JSON: {"agent": "...", "session_id": "...", "call_id": "...",
+ "pid": N, "ppid": N, "cmdline": "..."}
+"""
+
+import json
+import os
+import re
+import sys
+
+
+def read_proc_file(pid: int, name: str) -> str | None:
+ """Read /proc/<pid>/<name>, return stripped content or None."""
+ try:
+ with open(f"/proc/{pid}/{name}", "rb") as f:
+ raw = f.read()
+ return raw.decode("utf-8", errors="replace").strip("\x00\n ")
+ except (FileNotFoundError, PermissionError, ProcessLookupError):
+ return None
+
+
+def read_cmdline(pid: int) -> str | None:
+ """Read null-joined argv from /proc/<pid>/cmdline."""
+ raw = read_proc_file(pid, "cmdline")
+ if raw is None:
+ return None
+ return raw.replace("\x00", " ").strip()
+
+
+def read_comm(pid: int) -> str | None:
+ """Read process comm name from /proc/<pid>/comm."""
+ return read_proc_file(pid, "comm")
+
+
+def read_status_field(pid: int, field: str) -> str | None:
+ """Read a specific field value from /proc/<pid>/status."""
+ raw = read_proc_file(pid, "status")
+ if raw is None:
+ return None
+ for line in raw.splitlines():
+ if line.startswith(field + ":"):
+ return line.split(":", 1)[-1].strip()
+ return None
+
+
+def read_ppid(pid: int) -> int | None:
+ """Get parent PID from /proc/<pid>/status."""
+ val = read_status_field(pid, "PPid")
+ if val is None:
+ return None
+ try:
+ return int(val)
+ except ValueError:
+ return None
+
+
+def read_environ_dict(pid: int) -> dict[str, str]:
+ """Parse /proc/<pid>/environ into a dict."""
+ raw = read_proc_file(pid, "environ")
+ if raw is None:
+ return {}
+ result = {}
+ for entry in raw.split("\x00"):
+ if "=" in entry:
+ k, _, v = entry.partition("=")
+ result[k] = v
+ return result
+
+
+def get_own_environ() -> dict[str, str]:
+ """Get our own environment via /proc/self/environ (not os.environ)."""
+ return read_environ_dict(os.getpid())
+
+
+def walk_process_tree() -> list[dict]:
+ """Walk up from our PID to init, return ordered list of process info dicts."""
+ pid = os.getpid()
+ tree = []
+ visited = set()
+ while pid is not None and pid > 1 and pid not in visited:
+ visited.add(pid)
+ cmdline = read_cmdline(pid)
+ comm = read_comm(pid)
+ env = read_environ_dict(pid)
+ ppid = read_ppid(pid)
+ tree.append({
+ "pid": pid,
+ "ppid": ppid,
+ "comm": comm,
+ "cmdline": cmdline,
+ "env": env,
+ })
+ pid = ppid
+ return tree
+
+
+# ── Agent detection patterns ──────────────────────────────────────────────
+
+PATTERNS: list[tuple[str, re.Pattern, str]] = [
+ # (agent_name, cmdline_regex, description)
+ ("hermes", re.compile(r"python.*hermes_cli", re.I), "python -m hermes_cli.main"),
+ ("hermes", re.compile(r"hermes[_-]gateway", re.I), "hermes gateway"),
+ ("claude", re.compile(r"claude", re.I), "claude binary"),
+ ("claude", re.compile(r"claude[_-]code", re.I), "claude-code"),
+ ("codex", re.compile(r"codex\b(?!\s+exec)", re.I), "codex binary"),
+ ("codex", re.compile(r"codex[_-]cli", re.I), "codex-cli"),
+]
+
+SESSION_ENV_VARS: dict[str, list[str]] = {
+ "hermes": ["HERMES_SESSION_ID"],
+ "claude": [
+ "CLAUDE_CODE_CHILD_SESSION",
+ "CLAUDE_CODE_SESSION_LOG",
+ "CLAUDE_SESSION_INGRESS_TOKEN_FILE",
+ ],
+ "codex": [
+ "CODEX_THREAD_ID",
+ "CODEX_EVENT_ID",
+ "CODEX_CALL_ID",
+ ],
+}
+
+CALL_ENV_VARS: dict[str, list[str]] = {
+ "hermes": [],
+ "claude": [],
+
+"codex": ["CODEX_CALL_ID", "CODEX_EVENT_ID", "CODEX_TURN_ID"],
+}
+
+
+def detect_agent(tree: list[dict]) -> dict:
+ """Detect which agent called us by analyzing the process tree."""
+ self_pid = tree[0]["pid"] if tree else os.getpid()
+ self_cmdline = tree[0]["cmdline"] if tree else None
+
+ result: dict = {
+ "agent": "unknown",
+ "session_id": None,
+ "call_id": None,
+ "pid": self_pid,
+ "ppid": tree[0]["ppid"] if len(tree) > 0 else None,
+ "cmdline": self_cmdline,
+ "ancestors": [],
+ }
+
+ for i, node in enumerate(tree):
+ cmdline = node["cmdline"] or ""
+ comm = node["comm"] or ""
+ env = node["env"]
+
+ ancestor_info = {
+ "pid": node["pid"],
+ "comm": comm,
+ "cmdline_preview": cmdline[:120] + ("..." if len(cmdline) > 120 else ""),
+ "agent_hit": None,
+ }
+
+ for agent_name, pattern, desc in PATTERNS:
+ if pattern.search(cmdline) or pattern.search(comm):
+ if result["agent"] == "unknown":
+ result["agent"] = agent_name
+ ancestor_info["agent_hit"] = f"{agent_name} ({desc})"
+ break
+
+ if i > 0 and comm == "hermes":
+ if result["agent"] == "unknown":
+ result["agent"] = "hermes"
+ ancestor_info["agent_hit"] = "hermes (comm=hermes)"
+
+ for agent_name, var_names in SESSION_ENV_VARS.items():
+ for var in var_names:
+ if var in env and env[var]:
+ if result["agent"] == "unknown":
+ result["agent"] = agent_name
+ if not result["session_id"]:
+ result["session_id"] = env[var]
+ ancestor_info["agent_hit"] = (
+ ancestor_info["agent_hit"] or ""
+ ) + f" {agent_name}({var}={env[var][:60]})"
+ break
+
+ for agent_name, var_names in CALL_ENV_VARS.items():
+ for var in var_names:
+ if var in env and env[var]:
+ if not result["call_id"]:
+ result["call_id"] = env[var]
+ break
+
+ result["ancestors"].append(ancestor_info)
+
+ return result
+
+
+def main():
+ tree = walk_process_tree()
+ result = detect_agent(tree)
+ output = {k: v for k, v in result.items() if k != "ancestors"}
+ print(json.dumps(output, indent=2))
+
+
+if __name__ == "__main__":
+ main()