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
+38 -1
View File
@@ -1,3 +1,40 @@
# hermes-cc-bridge # hermes-cc-bridge
Hermes ↔ Claude Code SDK Bridge: hook-driven progress tracking, heartbeat detection, crash recovery Hermes ↔ Claude Code SDK Bridge hook-driven progress tracking with crash recovery.
## Features
- **Hook-driven completion**: Stop event = done signal, no timeout guessing
- **Real-time progress**: PostToolUse events → tool breakdown, elapsed time
- **Heartbeat detection** (v3.2): 120s no tool calls → check process alive → auto-recover
- **Crash recovery**: CC process dead without Stop event → recover from hook state files
- **Session ID tracking** (v3.2): hook_monitor dynamically tracks session_id (was broken when None at startup)
## Usage
```bash
python3 cc_sdk.py "fix the bug" --cwd ~/project --max-turns 8 --timeout 180 --json
python3 cc_sdk.py --progress <session_id>
```
## Files
- `cc_sdk.py` — SDK bridge (v3.2)
- `hermes_hook.py` — Hook script for Claude Code settings.json
## Hook Setup
In `~/.claude/settings.json`:
```json
{
"hooks": {
"Stop": [{"matcher": "", "hooks": [{"type": "command", "command": "python3 /path/to/hermes_hook.py", "timeout": 10}]}],
"PostToolUse": [{"matcher": "", "hooks": [{"type": "command", "command": "python3 /path/to/hermes_hook.py", "timeout": 5}]}],
"Notification": [{"matcher": "", "hooks": [{"type": "command", "command": "python3 /path/to/hermes_hook.py", "timeout": 10}]}]
}
}
```
## License
MIT
+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()
+196
View File
@@ -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()