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:
2026-05-25 16:36:44 +08:00
parent e4920e9b3e
commit a3a15f2c4f
3 changed files with 866 additions and 1 deletions
+632
View File
@@ -0,0 +1,632 @@
#!/usr/bin/env python3
"""
Claude Code SDK Bridge v3.2 — Hermes ↔ Claude Code 稳定调用桥
基于 Claude Code v2.1.150 + claude-code-sdk v0.0.25 + Hermes Hooks Plugin
用法:
python3 cc_sdk.py "任务" --cwd /path --max-turns 3 --tools Read,Write
python3 cc_sdk.py "任务" --bare --timeout 30
python3 cc_sdk.py "继续" --resume <session_id> --max-turns 5
v3.0 更新:
- Hook 驱动完成检测:监听 CC 的 Stop 事件,不再依赖 timeout 猜测
- 实时进度追踪:PostToolUse 事件报告当前正在用什么工具
- 超时后自动提取已完成的结果(即使进程被杀)
- --no-hooks: 禁用 hook 监听(向后兼容)
"""
import asyncio
import argparse
import json
import sys
import time
import os
import glob
import subprocess
from pathlib import Path
try:
from claude_code_sdk import query, ClaudeCodeOptions
except ImportError:
print("ERROR: claude-code-sdk not installed. Run: pip3 install claude-code-sdk", file=sys.stderr)
sys.exit(1)
STATUS_DIR = Path("/tmp/hermes-cc-status")
# 心跳阈值:超过这个时间没有 PostToolUse 事件,认为 CC 可能卡死
STALE_THRESHOLD_SECONDS = 120
# 检查间隔
HEARTBEAT_INTERVAL = 5
def is_claude_process_alive() -> bool:
"""检查 claude 子进程是否还在运行"""
try:
result = subprocess.run(
["pgrep", "-f", "claude.*sdk\\|claude-code\\|@anthropic-ai/claude-code"],
capture_output=True, text=True, timeout=3
)
return result.returncode == 0
except Exception:
return False
def find_status_file(session_id: str = None) -> Path | None:
"""查找状态文件"""
if session_id:
safe_id = session_id.replace("/", "_")[:64]
p = STATUS_DIR / f"{safe_id}.json"
if p.exists():
return p
# 查找最新的状态文件
files = sorted(STATUS_DIR.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True)
return files[0] if files else None
def read_status(status_file: Path) -> dict | None:
"""读取状态文件"""
try:
with open(status_file) as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return None
def cleanup_status(session_id: str):
"""清理状态文件"""
if session_id:
safe_id = session_id.replace("/", "_")[:64]
p = STATUS_DIR / f"{safe_id}.json"
p.unlink(missing_ok=True)
def build_progress_report(session_id: str, start_time: float = None) -> dict:
"""
扫描所有 PostToolUse 事件文件,构建结构化进度报告。
可被外部轮询调用(--progress 模式)。
start_time 为 None 时从最早事件推算。
"""
if not session_id:
return {"error": "no session_id"}
safe_id = session_id.replace("/", "_")[:64]
tool_counts = {}
tool_timeline = []
total_tools = 0
earliest_ts = None
# 扫描所有 PostToolUse 事件
for f in sorted(STATUS_DIR.glob(f"{safe_id}-PostToolUse-*.json")):
status = read_status(f)
if not status:
continue
ts = status.get("timestamp", 0)
if earliest_ts is None or ts < earliest_ts:
earliest_ts = ts
data = status.get("data", {})
tool_name = data.get("tool_name", "?")
tool_counts[tool_name] = tool_counts.get(tool_name, 0) + 1
total_tools += 1
tool_timeline.append({
"tool": tool_name,
"ts": ts,
"input": data.get("tool_input", "")[:80],
})
# 推算 start_time
if start_time is None:
start_time = earliest_ts if earliest_ts else time.time()
elapsed = time.time() - start_time
# 检查是否已完成(Stop 事件)
stop_file = STATUS_DIR / f"{safe_id}.json"
completed = False
stop_stats = {}
if stop_file.exists():
stop_status = read_status(stop_file)
if stop_status and stop_status.get("event") == "Stop":
completed = True
stop_stats = stop_status.get("data", {}).get("stats", {})
# 写入进度文件供外部轮询
progress = {
"session_id": session_id,
"elapsed": round(elapsed, 1),
"completed": completed,
"total_tool_calls": total_tools,
"tool_breakdown": tool_counts,
"last_tool": tool_timeline[-1] if tool_timeline else None,
"stop_stats": stop_stats,
}
progress_file = STATUS_DIR / f"{safe_id}-progress.json"
tmp = progress_file.with_suffix(".tmp")
with open(tmp, "w") as f:
json.dump(progress, f, ensure_ascii=False, indent=2)
tmp.rename(progress_file)
return progress
async def wait_for_stop_event(
timeout: float,
poll_interval: float = 0.5,
session_id: str = None,
quiet: bool = False,
progress_callback=None,
) -> dict | None:
"""
等待 CC 的 Stop 事件(主完成信号)。
实时追踪所有 PostToolUse 事件,构建结构化进度报告。
每次发现新事件时更新进度文件 + 调用回调。
"""
start = time.time()
seen_events = set() # 已处理的事件文件名
last_report = None
# 清理旧的状态文件
if session_id:
cleanup_status(session_id)
while time.time() - start < timeout:
# 检查 Stop 事件(完成信号)
if session_id:
safe_id = session_id.replace("/", "_")[:64]
stop_file = STATUS_DIR / f"{safe_id}.json"
if stop_file.exists():
status = read_status(stop_file)
if status and status.get("event") == "Stop":
elapsed = time.time() - start
stats = status.get("data", {}).get("stats", {})
if not quiet:
tools = ", ".join(stats.get("tools_used", []))
t = stats.get("total_turns", "?")
tc = stats.get("total_tool_calls", "?")
print(f"\n[CC Progress] ✅ 完成! {elapsed:.0f}s | turns={t} | tools={tc} | used: {tools}",
file=sys.stderr, flush=True)
# 最终进度报告
report = build_progress_report(session_id, start)
if progress_callback:
progress_callback(report)
return status
# 扫描新的 PostToolUse 事件
if session_id:
safe_id = session_id.replace("/", "_")[:64]
new_events = False
for f in sorted(STATUS_DIR.glob(f"{safe_id}-PostToolUse-*.json")):
fname = f.name
if fname not in seen_events:
seen_events.add(fname)
new_events = True
if new_events:
report = build_progress_report(session_id, start)
last_report = report
if not quiet:
tc = report["total_tool_calls"]
lt = report.get("last_tool", {}).get("tool", "?")
elapsed = report["elapsed"]
# 只在工具切换或每5次时打印
if tc <= 1 or tc % 5 == 0 or (report.get("last_tool", {}).get("tool") !=
(last_report or {}).get("last_tool", {}).get("tool")):
bd = report['tool_breakdown']
print(f"\n[CC Progress] {elapsed:.0f}s | tool #{tc}: {lt} | breakdown: {bd}",
file=sys.stderr, flush=True)
if progress_callback:
progress_callback(report)
await asyncio.sleep(poll_interval)
return None
async def run_task(
prompt: str,
cwd: str = ".",
max_turns: int = 5,
allowed_tools: list[str] | None = None,
effort: str = "medium",
timeout: int = 180,
output_json: bool = False,
model: str | None = None,
system_prompt: str | None = None,
append_system_prompt: str | None = None,
append_system_prompt_file: str | None = None,
bare: bool = False,
resume: str | None = None,
continue_conversation: bool = False,
mcp_config: str | None = None,
disallowed_tools: list[str] | None = None,
env: dict[str, str] | None = None,
quiet: bool = False,
use_hooks: bool = True,
) -> dict:
"""Run a Claude Code task via SDK, return structured result."""
start_time = time.time()
opts = {
"max_turns": max_turns,
"cwd": os.path.abspath(cwd),
"permission_mode": "bypassPermissions",
}
if allowed_tools:
opts["allowed_tools"] = allowed_tools
if disallowed_tools:
opts["disallowed_tools"] = disallowed_tools
if model:
opts["model"] = model
if resume:
opts["resume"] = resume
opts.pop("max_turns", None)
if continue_conversation:
opts["continue_conversation"] = True
opts.pop("max_turns", None)
if env:
opts["env"] = env
# Bare 模式
if bare:
env_dict = opts.get("env", {})
env_dict["CLAUDE_CODE_SIMPLE"] = "1"
opts["env"] = env_dict
# MCP config
extra_args = {}
if mcp_config:
extra_args["mcp-config"] = mcp_config
if extra_args:
opts["extra_args"] = extra_args
# System prompt
if system_prompt:
opts["system_prompt"] = system_prompt
if append_system_prompt:
opts["append_system_prompt"] = append_system_prompt
if append_system_prompt_file and os.path.exists(append_system_prompt_file):
with open(append_system_prompt_file) as f:
file_content = f.read()
existing = opts.get("append_system_prompt", "")
opts["append_system_prompt"] = (existing + "\n" + file_content).strip() if existing else file_content
# Effort
effort_map = {
"low": "Be concise. Quick answers only.",
"medium": "",
"high": "Think carefully and thoroughly before acting.",
"max": "Use ultrathink: deeply reason about every aspect before any action.",
}
if effort in effort_map and effort_map[effort]:
extra = effort_map[effort]
existing = opts.get("append_system_prompt", "")
opts["append_system_prompt"] = (existing + "\n" + extra).strip() if existing else extra
options = ClaudeCodeOptions(**opts)
texts = []
tool_uses = []
errors = []
message_count = 0
last_text = ""
result_message = None
session_id = None
# 同时运行 SDK 查询和 Hook 监听
async def sdk_loop():
nonlocal last_text, result_message, message_count, session_id
try:
async for msg in query(prompt=prompt, options=options):
message_count += 1
if hasattr(msg, "content") and isinstance(msg.content, list):
for block in msg.content:
if hasattr(block, "text"):
texts.append(block.text)
last_text = block.text
elif hasattr(block, "type") and block.type == "tool_use":
tool_uses.append({
"tool": getattr(block, "name", "unknown"),
"input_summary": str(getattr(block, "input", {}))[:100],
})
if hasattr(msg, "subtype"):
result_message = msg
subtype = msg.subtype
if "error" in subtype:
errors.append(f"CC error: {subtype}")
if hasattr(msg, "session_id"):
session_id = msg.session_id
# Stream to stderr
if not quiet and not output_json and hasattr(msg, "content"):
for block in (msg.content if isinstance(msg.content, list) else []):
if hasattr(block, "text") and block.text:
print(block.text, end="", file=sys.stderr, flush=True)
except Exception as e:
errors.append(f"SDK EXCEPTION: {str(e)}")
# SDK 崩溃时尝试从 hook 状态文件恢复已完成的工作
_recover_from_hooks()
def _recover_from_hooks():
"""从 hook 状态文件恢复:Stop 事件 + session_id + 最后消息 + 工具使用"""
nonlocal last_text, session_id
# 尝试所有已知的 session_id
candidate_ids = set()
if session_id:
candidate_ids.add(session_id)
if result_message and hasattr(result_message, "session_id"):
candidate_ids.add(result_message.session_id)
# 从 hook 状态目录扫描最近的文件
for f in sorted(STATUS_DIR.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True)[:5]:
status = read_status(f)
if status and status.get("session_id"):
candidate_ids.add(status["session_id"])
for sid in candidate_ids:
safe_id = sid.replace("/", "_")[:64]
stop_file = STATUS_DIR / f"{safe_id}.json"
# 路径 1: 有 Stop 事件(CC 完成了但 SDK 没收到结果)
if stop_file.exists():
status = read_status(stop_file)
if status and status.get("event") == "Stop":
data = status.get("data", {})
stats = data.get("stats", {})
if not last_text and data.get("last_message"):
last_text = data["last_message"]
texts.append(last_text)
if not session_id:
session_id = sid
errors.append(f"Recovered from hook after SDK crash (tools={stats.get('total_tool_calls', '?')})")
return
# 路径 2: 没有 Stop 但有 PostToolUseCC 做了一部分工作后崩溃)
tool_files = sorted(STATUS_DIR.glob(f"{safe_id}-PostToolUse-*.json"))
if tool_files and not tool_uses:
for tf in tool_files:
ts = read_status(tf)
if ts:
tool_name = ts.get("data", {}).get("tool_name", "?")
tool_input = ts.get("data", {}).get("tool_input", "")[:100]
tool_uses.append({"tool": tool_name, "input_summary": tool_input})
if not session_id:
session_id = sid
errors.append(f"Partial recovery from hooks: {len(tool_uses)} tool calls captured")
return
async def hook_monitor():
"""
智能 Hook 监听器(v3.2 修复):
- 动态追踪 session_id(解决启动时为 None 的问题)
- 心跳检测:长时间无 PostToolUse 事件 → 检查进程是否存活
- CC 进程死亡但无 Stop 事件 → 从 hook 文件恢复
"""
if not use_hooks:
return None
nonlocal session_id, errors, last_text, texts
start = time.time()
seen_events = set()
last_tool_time = time.time() # 最后一次收到 PostToolUse 的时间
last_known_sid = None
while time.time() - start < timeout + 15:
# 动态获取 session_idsdk_loop 可能在运行中赋值)
current_sid = session_id
# 如果 session_id 从 None 变为有值,清理旧文件
if current_sid and current_sid != last_known_sid:
last_known_sid = current_sid
cleanup_status(current_sid)
if current_sid:
safe_id = current_sid.replace("/", "_")[:64]
# 1. 检查 Stop 事件
stop_file = STATUS_DIR / f"{safe_id}.json"
if stop_file.exists():
status = read_status(stop_file)
if status and status.get("event") == "Stop":
elapsed = time.time() - start
stats = status.get("data", {}).get("stats", {})
if not quiet:
tools = ", ".join(stats.get("tools_used", []))
t = stats.get("total_turns", "?")
tc = stats.get("total_tool_calls", "?")
print(f"\n[CC Progress] ✅ 完成! {elapsed:.0f}s | turns={t} | tools={tc} | used: {tools}",
file=sys.stderr, flush=True)
return status
# 2. 扫描新的 PostToolUse 事件
new_events = False
for f in sorted(STATUS_DIR.glob(f"{safe_id}-PostToolUse-*.json")):
fname = f.name
if fname not in seen_events:
seen_events.add(fname)
new_events = True
last_tool_time = time.time() # 更新心跳
if new_events:
report = build_progress_report(current_sid, start)
if not quiet:
tc = report["total_tool_calls"]
lt = report.get("last_tool", {}).get("tool", "?")
elapsed = report["elapsed"]
if tc <= 1 or tc % 5 == 0:
bd = report['tool_breakdown']
print(f"\n[CC Progress] {elapsed:.0f}s | tool #{tc}: {lt} | breakdown: {bd}",
file=sys.stderr, flush=True)
# 3. 心跳检测:长时间无事件
silence_duration = time.time() - last_tool_time
if silence_duration > STALE_THRESHOLD_SECONDS:
alive = is_claude_process_alive()
if not alive:
# CC 进程已死,无 Stop 事件 → 强制恢复
errors.append(f"CC process dead after {silence_duration:.0f}s silence, recovering from hooks")
if not quiet:
print(f"\n[CC Progress] ⚠️ CC 进程已死({silence_duration:.0f}s 无响应),从 hook 文件恢复...",
file=sys.stderr, flush=True)
# 尝试从 hook 文件恢复
_recover_from_hooks()
return None
else:
# 进程还活着但没动静 → 可能在深度思考
if not quiet and silence_duration > STALE_THRESHOLD_SECONDS * 2:
print(f"\n[CC Progress] ⏳ CC {silence_duration:.0f}s 无工具调用(进程存活,可能在思考)",
file=sys.stderr, flush=True)
# 重置避免重复打印
last_tool_time = time.time()
await asyncio.sleep(HEARTBEAT_INTERVAL)
return None
# 并行运行
try:
async with asyncio.timeout(timeout + 5):
sdk_task = asyncio.create_task(sdk_loop())
hook_task = asyncio.create_task(hook_monitor())
# 等待 SDK 完成(主要路径)
await sdk_task
# 取消 hook 监听(SDK 已完成)
hook_task.cancel()
try:
await hook_task
except asyncio.CancelledError:
pass
except asyncio.TimeoutError:
errors.append(f"TIMEOUT after {timeout}s")
# 尝试从 hook 状态文件获取结果
hook_status = find_status_file(session_id)
if hook_status:
status = read_status(hook_status)
if status and status.get("event") == "Stop":
errors = [e for e in errors if "TIMEOUT" not in e]
errors.append(f"Recovered from hook after timeout")
stats = status.get("data", {}).get("stats", {})
if not last_text and status.get("data", {}).get("last_message"):
last_text = status["data"]["last_message"]
texts.append(last_text)
# 即使超时也构建进度报告
if session_id:
build_progress_report(session_id, start_time)
elapsed = time.time() - start_time
# 提取 session_id
if result_message and hasattr(result_message, "session_id"):
session_id = result_message.session_id
# 构建最终进度报告(含 hook 数据)
progress = {}
if session_id:
progress = build_progress_report(session_id, start_time)
result = {
"success": len(errors) == 0,
"text": last_text,
"full_text": "\n".join(texts),
"tool_uses": tool_uses,
"tool_count": len(tool_uses),
"message_count": message_count,
"elapsed_seconds": round(elapsed, 1),
"errors": errors,
"session_id": session_id,
"progress": progress,
}
return result
def main():
parser = argparse.ArgumentParser(description="Claude Code SDK Bridge v3.2 for Hermes")
parser.add_argument("prompt", nargs="?", default=None, help="Task prompt for Claude Code (optional with --progress)")
parser.add_argument("--cwd", default=".", help="Working directory (default: .)")
parser.add_argument("--max-turns", type=int, default=5, help="Max agentic turns (default: 5)")
parser.add_argument("--tools", default=None, help="Allowed tools, comma-separated (default: all)")
parser.add_argument("--disallowed-tools", default=None, help="Disallowed tools, comma-separated")
parser.add_argument("--effort", default="medium", choices=["low", "medium", "high", "max"],
help="Reasoning effort (default: medium)")
parser.add_argument("--timeout", type=int, default=180, help="Timeout in seconds (default: 180)")
parser.add_argument("--json", action="store_true", help="Output JSON result")
parser.add_argument("--model", default=None, help="Model override")
parser.add_argument("--system-prompt", default=None, help="Custom system prompt (replaces default)")
parser.add_argument("--append-system-prompt", default=None, help="Append to system prompt")
parser.add_argument("--append-system-prompt-file", default=None, help="Append file to system prompt")
parser.add_argument("--bare", action="store_true", help="Bare mode: faster startup (WARNING: disables hooks!)")
parser.add_argument("--resume", default=None, help="Resume a session by ID")
parser.add_argument("--continue", dest="continue_conversation", action="store_true",
help="Continue the most recent session in this directory")
parser.add_argument("--mcp-config", default=None, help="Path to MCP config JSON")
parser.add_argument("--env", default=None, help="Environment variables as JSON string")
parser.add_argument("--quiet", action="store_true", help="No streaming output to stderr")
parser.add_argument("--no-hooks", action="store_true", help="Disable hook-based completion detection")
parser.add_argument("--progress", default=None, metavar="SESSION_ID",
help="Query live progress of a running CC session (read-only, exits immediately)")
args = parser.parse_args()
# --progress 模式:查询进度并退出
if args.progress:
report = build_progress_report(args.progress)
print(json.dumps(report, ensure_ascii=False, indent=2))
sys.exit(0)
if not args.prompt:
parser.error("prompt is required (unless using --progress)")
# bare 模式警告
if args.bare and not args.no_hooks:
print("[cc_sdk] WARNING: --bare disables hooks! Use --no-hooks to silence this.", file=sys.stderr)
allowed_tools = args.tools.split(",") if args.tools else None
disallowed_tools = args.disallowed_tools.split(",") if args.disallowed_tools else None
env = json.loads(args.env) if args.env else None
result = asyncio.run(run_task(
prompt=args.prompt,
cwd=args.cwd,
max_turns=args.max_turns,
allowed_tools=allowed_tools,
disallowed_tools=disallowed_tools,
effort=args.effort,
timeout=args.timeout,
output_json=args.json or args.quiet,
model=args.model,
system_prompt=args.system_prompt,
append_system_prompt=args.append_system_prompt,
append_system_prompt_file=args.append_system_prompt_file,
bare=args.bare,
resume=args.resume,
continue_conversation=args.continue_conversation,
mcp_config=args.mcp_config,
env=env,
quiet=args.quiet,
use_hooks=not args.no_hooks,
))
if args.json:
print(json.dumps(result, ensure_ascii=False, indent=2))
else:
if result["success"]:
print(result["text"])
else:
print(f"FAILED: {'; '.join(result['errors'])}", file=sys.stderr)
if result["text"]:
print(result["text"])
if result.get("session_id"):
print(f"\nResume with: --resume {result['session_id']}", file=sys.stderr)
sys.exit(0 if result["success"] else 1)
if __name__ == "__main__":
main()