cleanup: remove clawd/openclaw references, fix permissions, add architecture docs
- Replace all ~/clawd and ~/.clawdbot paths with ~/echo-core equivalents in tools (git_commit, ralph_prd_generator, backup_config, lead-gen) - Update personality files: TOOLS.md repo/paths, AGENTS.md security audit cmd - Migrate HANDOFF.md architectural decisions to docs/architecture.md - Tighten credentials/ dir to 700, add to .gitignore - Add .claude/ and *.pid to .gitignore - Various adapter, router, and session improvements from prior work Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -12,9 +12,11 @@ import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_invoke_log = logging.getLogger("echo-core.invoke")
|
||||
@@ -31,7 +33,7 @@ _SESSIONS_FILE = SESSIONS_DIR / "active.json"
|
||||
|
||||
VALID_MODELS = {"haiku", "sonnet", "opus"}
|
||||
DEFAULT_MODEL = "sonnet"
|
||||
DEFAULT_TIMEOUT = 120 # seconds
|
||||
DEFAULT_TIMEOUT = 300 # seconds
|
||||
|
||||
CLAUDE_BIN = os.environ.get("CLAUDE_BIN", "claude")
|
||||
|
||||
@@ -156,12 +158,20 @@ def _save_sessions(data: dict) -> None:
|
||||
raise
|
||||
|
||||
|
||||
def _run_claude(cmd: list[str], timeout: int) -> dict:
|
||||
def _run_claude(
|
||||
cmd: list[str],
|
||||
timeout: int,
|
||||
on_text: Callable[[str], None] | None = None,
|
||||
) -> dict:
|
||||
"""Run a Claude CLI command and return parsed output.
|
||||
|
||||
Expects ``--output-format stream-json --verbose``. Parses the newline-
|
||||
delimited JSON stream, collecting every text block from ``assistant``
|
||||
messages and metadata from the final ``result`` line.
|
||||
|
||||
If *on_text* is provided it is called with each intermediate text block
|
||||
as soon as it arrives (before the process finishes), enabling real-time
|
||||
streaming to adapters.
|
||||
"""
|
||||
if not shutil.which(CLAUDE_BIN):
|
||||
raise FileNotFoundError(
|
||||
@@ -169,59 +179,92 @@ def _run_claude(cmd: list[str], timeout: int) -> dict:
|
||||
"Install: https://docs.anthropic.com/en/docs/claude-code"
|
||||
)
|
||||
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
env=_safe_env(),
|
||||
cwd=PROJECT_ROOT,
|
||||
)
|
||||
|
||||
# Watchdog thread: kill the process if it exceeds the timeout
|
||||
timed_out = threading.Event()
|
||||
|
||||
def _watchdog():
|
||||
try:
|
||||
proc.wait(timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
timed_out.set()
|
||||
try:
|
||||
proc.kill()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
watchdog = threading.Thread(target=_watchdog, daemon=True)
|
||||
watchdog.start()
|
||||
|
||||
# --- Parse stream-json output line by line ---
|
||||
text_blocks: list[str] = []
|
||||
result_obj: dict | None = None
|
||||
intermediate_count = 0
|
||||
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
env=_safe_env(),
|
||||
cwd=PROJECT_ROOT,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
for line in proc.stdout:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
obj = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
msg_type = obj.get("type")
|
||||
|
||||
if msg_type == "assistant":
|
||||
message = obj.get("message", {})
|
||||
for block in message.get("content", []):
|
||||
if block.get("type") == "text":
|
||||
text = block.get("text", "").strip()
|
||||
if text:
|
||||
text_blocks.append(text)
|
||||
if on_text:
|
||||
try:
|
||||
on_text(text)
|
||||
intermediate_count += 1
|
||||
except Exception:
|
||||
logger.exception("on_text callback error")
|
||||
|
||||
elif msg_type == "result":
|
||||
result_obj = obj
|
||||
finally:
|
||||
# Ensure process resources are cleaned up
|
||||
proc.stdout.close()
|
||||
try:
|
||||
proc.wait(timeout=30)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
proc.wait()
|
||||
|
||||
stderr_output = proc.stderr.read()
|
||||
proc.stderr.close()
|
||||
|
||||
if timed_out.is_set():
|
||||
raise TimeoutError(f"Claude CLI timed out after {timeout}s")
|
||||
|
||||
if proc.returncode != 0:
|
||||
detail = proc.stderr[:500] or proc.stdout[:500]
|
||||
logger.error("Claude CLI stdout: %s", proc.stdout[:1000])
|
||||
logger.error("Claude CLI stderr: %s", proc.stderr[:1000])
|
||||
stdout_tail = "\n".join(text_blocks[-3:]) if text_blocks else ""
|
||||
detail = stderr_output[:500] or stdout_tail[:500]
|
||||
logger.error("Claude CLI stderr: %s", stderr_output[:1000])
|
||||
raise RuntimeError(
|
||||
f"Claude CLI error (exit {proc.returncode}): {detail}"
|
||||
)
|
||||
|
||||
# --- Parse stream-json output ---
|
||||
text_blocks: list[str] = []
|
||||
result_obj: dict | None = None
|
||||
|
||||
for line in proc.stdout.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
obj = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
msg_type = obj.get("type")
|
||||
|
||||
if msg_type == "assistant":
|
||||
# Extract text from content blocks
|
||||
message = obj.get("message", {})
|
||||
for block in message.get("content", []):
|
||||
if block.get("type") == "text":
|
||||
text = block.get("text", "").strip()
|
||||
if text:
|
||||
text_blocks.append(text)
|
||||
|
||||
elif msg_type == "result":
|
||||
result_obj = obj
|
||||
|
||||
if result_obj is None:
|
||||
raise RuntimeError(
|
||||
"Failed to parse Claude CLI output: no result line in stream"
|
||||
)
|
||||
|
||||
# Build a dict compatible with the old json output format
|
||||
combined_text = "\n\n".join(text_blocks) if text_blocks else result_obj.get("result", "")
|
||||
|
||||
return {
|
||||
@@ -232,6 +275,7 @@ def _run_claude(cmd: list[str], timeout: int) -> dict:
|
||||
"cost_usd": result_obj.get("cost_usd", 0),
|
||||
"duration_ms": result_obj.get("duration_ms", 0),
|
||||
"num_turns": result_obj.get("num_turns", 0),
|
||||
"intermediate_count": intermediate_count,
|
||||
}
|
||||
|
||||
|
||||
@@ -273,10 +317,14 @@ def start_session(
|
||||
message: str,
|
||||
model: str = DEFAULT_MODEL,
|
||||
timeout: int = DEFAULT_TIMEOUT,
|
||||
on_text: Callable[[str], None] | None = None,
|
||||
) -> tuple[str, str]:
|
||||
"""Start a new Claude CLI session for a channel.
|
||||
|
||||
Returns (response_text, session_id).
|
||||
|
||||
If *on_text* is provided, each intermediate Claude text block is passed
|
||||
to the callback as soon as it arrives.
|
||||
"""
|
||||
if model not in VALID_MODELS:
|
||||
raise ValueError(
|
||||
@@ -297,7 +345,7 @@ def start_session(
|
||||
]
|
||||
|
||||
_t0 = time.monotonic()
|
||||
data = _run_claude(cmd, timeout)
|
||||
data = _run_claude(cmd, timeout, on_text=on_text)
|
||||
_elapsed_ms = int((time.monotonic() - _t0) * 1000)
|
||||
|
||||
for field in ("result", "session_id"):
|
||||
@@ -342,8 +390,13 @@ def resume_session(
|
||||
session_id: str,
|
||||
message: str,
|
||||
timeout: int = DEFAULT_TIMEOUT,
|
||||
on_text: Callable[[str], None] | None = None,
|
||||
) -> str:
|
||||
"""Resume an existing Claude session by ID. Returns response text."""
|
||||
"""Resume an existing Claude session by ID. Returns response text.
|
||||
|
||||
If *on_text* is provided, each intermediate Claude text block is passed
|
||||
to the callback as soon as it arrives.
|
||||
"""
|
||||
# Find channel/model for logging
|
||||
sessions = _load_sessions()
|
||||
_log_channel = "?"
|
||||
@@ -365,7 +418,7 @@ def resume_session(
|
||||
]
|
||||
|
||||
_t0 = time.monotonic()
|
||||
data = _run_claude(cmd, timeout)
|
||||
data = _run_claude(cmd, timeout, on_text=on_text)
|
||||
_elapsed_ms = int((time.monotonic() - _t0) * 1000)
|
||||
|
||||
if not data.get("result"):
|
||||
@@ -407,13 +460,14 @@ def send_message(
|
||||
message: str,
|
||||
model: str = DEFAULT_MODEL,
|
||||
timeout: int = DEFAULT_TIMEOUT,
|
||||
on_text: Callable[[str], None] | None = None,
|
||||
) -> str:
|
||||
"""High-level convenience: auto start or resume based on channel state."""
|
||||
session = get_active_session(channel_id)
|
||||
if session is not None:
|
||||
return resume_session(session["session_id"], message, timeout)
|
||||
return resume_session(session["session_id"], message, timeout, on_text=on_text)
|
||||
response_text, _session_id = start_session(
|
||||
channel_id, message, model, timeout
|
||||
channel_id, message, model, timeout, on_text=on_text
|
||||
)
|
||||
return response_text
|
||||
|
||||
|
||||
Reference in New Issue
Block a user