Files
Evilom a3a15f2c4f v3.2: heartbeat detection, session_id fix, crash recovery
- Fix: hook_monitor() now dynamically tracks session_id (was None at startup)
- Fix: is_claude_process_alive() checks if CC process is still running
- Fix: 120s stale detection → process dead? recover from hooks : report thinking
- Add: HEARTBEAT_INTERVAL=5s polling loop
- Add: STALE_THRESHOLD_SECONDS=120 configurable threshold
2026-05-25 16:36:44 +08:00

197 lines
6.4 KiB
Python

#!/usr/bin/env python3
"""
Hermes ↔ Claude Code Hook Bridge
从 Claude Code 的 hook 事件中提取信息,写入状态文件供 cc_sdk.py 读取。
支持事件:Stop, Notification, PostToolUse, SubagentStop, SessionStart
状态文件:/tmp/hermes-cc-{session_id}.json
格式:{"event": "Stop", "session_id": "...", "timestamp": ..., "data": {...}}
"""
import json
import os
import sys
import time
from pathlib import Path
STATUS_DIR = Path("/tmp/hermes-cc-status")
STATUS_DIR.mkdir(parents=True, exist_ok=True)
def read_stdin_event() -> dict:
"""从 stdin 读取 CC 传来的 hook 事件数据"""
try:
if sys.stdin.isatty():
return {}
raw = sys.stdin.read()
return json.loads(raw) if raw.strip() else {}
except (json.JSONDecodeError, IOError):
return {}
def write_status(session_id: str, event_name: str, data: dict):
"""写入状态文件。Stop 事件覆盖主文件,其他事件写独立文件。"""
if not session_id:
session_id = data.get("session_id", "unknown")
safe_id = session_id.replace("/", "_")[:64]
status = {
"event": event_name,
"session_id": session_id,
"timestamp": time.time(),
"iso_time": time.strftime("%Y-%m-%d %H:%M:%S"),
"data": data,
}
if event_name == "Stop":
# Stop 事件写主文件(覆盖)
status_file = STATUS_DIR / f"{safe_id}.json"
else:
# 其他事件写带时间戳的独立文件(不覆盖)
ts = int(time.time() * 1000)
status_file = STATUS_DIR / f"{safe_id}-{event_name}-{ts}.json"
tmp_file = status_file.with_suffix(".tmp")
with open(tmp_file, "w") as f:
json.dump(status, f, ensure_ascii=False, indent=2)
tmp_file.rename(status_file)
def parse_transcript_stats(transcript_path: str) -> dict:
"""从 transcript JSONL 解析统计信息"""
stats = {
"total_turns": 0,
"total_tool_calls": 0,
"total_tokens": 0,
"tools_used": [],
"files_modified": [],
"errors": [],
}
if not transcript_path or not os.path.exists(transcript_path):
return stats
try:
with open(transcript_path, "r") as f:
for line in f:
try:
obj = json.loads(line)
except json.JSONDecodeError:
continue
msg_type = obj.get("type", "")
# 统计 assistant 消息
if msg_type == "assistant":
stats["total_turns"] += 1
content = obj.get("message", {}).get("content", [])
if isinstance(content, list):
for block in content:
if isinstance(block, dict) and block.get("type") == "tool_use":
stats["total_tool_calls"] += 1
tool_name = block.get("name", "unknown")
if tool_name not in stats["tools_used"]:
stats["tools_used"].append(tool_name)
# 统计 token 使用
usage = obj.get("usage", {})
if usage:
stats["total_tokens"] += usage.get("output_tokens", 0)
# 提取修改的文件
if msg_type == "tool_result":
tool_input = obj.get("input", {})
if isinstance(tool_input, dict):
file_path = tool_input.get("file_path") or tool_input.get("path", "")
if file_path and file_path not in stats["files_modified"]:
stats["files_modified"].append(file_path)
except Exception:
pass
return stats
def main():
event = read_stdin_event()
if not event:
return
event_name = event.get("hook_event_name", "Unknown")
session_id = event.get("session_id", "")
cwd = event.get("cwd", "")
transcript_path = event.get("transcript_path", "")
stop_hook_reason = event.get("stop_hook_reason", "")
# 构建事件数据
data = {
"cwd": cwd,
"stop_reason": stop_hook_reason,
}
# Stop 事件:解析 transcript 获取统计
if event_name == "Stop" and transcript_path:
data["transcript_path"] = transcript_path
data["stats"] = parse_transcript_stats(transcript_path)
data["last_message"] = event.get("last_assistant_message", "")[:500]
# Notification 事件
if event_name == "Notification":
data["message"] = event.get("message", "")
# PostToolUse 事件:记录工具使用
if event_name == "PostToolUse":
data["tool_name"] = event.get("tool_name", "")
data["tool_input"] = str(event.get("tool_input", {}))[:200]
# SubagentStop 事件
if event_name == "SubagentStop":
data["agent_id"] = event.get("agent_id", "")
write_status(session_id, event_name, data)
# PostToolUse 时更新统一进度文件
if event_name == "PostToolUse":
update_live_progress(session_id)
def update_live_progress(session_id: str):
"""每次 PostToolUse 后扫描所有事件,写统一进度文件"""
if not session_id:
return
safe_id = session_id.replace("/", "_")[:64]
tool_counts = {}
total = 0
earliest = None
for f in sorted(STATUS_DIR.glob(f"{safe_id}-PostToolUse-*.json")):
try:
with open(f) as fh:
s = json.load(fh)
ts = s.get("timestamp", 0)
if earliest is None or ts < earliest:
earliest = ts
tool = s.get("data", {}).get("tool_name", "?")
tool_counts[tool] = tool_counts.get(tool, 0) + 1
total += 1
except Exception:
continue
elapsed = time.time() - earliest if earliest else 0
progress = {
"session_id": session_id,
"elapsed": round(elapsed, 1),
"completed": False,
"total_tool_calls": total,
"tool_breakdown": tool_counts,
"last_tool": tool,
"updated_at": time.strftime("%Y-%m-%d %H:%M:%S"),
}
progress_file = STATUS_DIR / f"{safe_id}-progress.json"
tmp = progress_file.with_suffix(".tmp")
with open(tmp, "w") as fh:
json.dump(progress, fh, ensure_ascii=False, indent=2)
tmp.rename(progress_file)
if __name__ == "__main__":
main()