a3a15f2c4f
- 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
633 lines
25 KiB
Python
633 lines
25 KiB
Python
#!/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 但有 PostToolUse(CC 做了一部分工作后崩溃)
|
||
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_id(sdk_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()
|