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

633 lines
25 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()