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
This commit is contained in:
+196
@@ -0,0 +1,196 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user