diff --git a/scripts/auto_detect/README.md b/scripts/auto_detect/README.md new file mode 100644 index 0000000..9ae8cfd --- /dev/null +++ b/scripts/auto_detect/README.md @@ -0,0 +1,55 @@ +# Star Office Auto-Detect Daemon + +自动检测本机运行的 Claude Code / Codex / OpenClaw 实例,并同步到 Star Office。 + +## 快速开始 + +```bash +# 使用默认配置(localhost:28791) +python scripts/auto_detect/daemon.py + +# 自定义配置 +python scripts/auto_detect/daemon.py \ + --url https://office.hyacinth.im \ + --key ocj_your_key \ + --interval 15 +``` + +## 环境变量 + +| 变量 | 默认值 | 说明 | +|------|--------|------| +| `STAR_OFFICE_URL` | `http://127.0.0.1:28791` | Office 后端地址 | +| `STAR_OFFICE_JOIN_KEY` | `ocj_example_team_01` | Join key | +| `STAR_OFFICE_INTERVAL` | `10` | 轮询间隔(秒) | + +## 架构 + +``` +detector.py — 进程检测(pgrep + lsof 获取工作目录) +client.py — Office API 客户端(join / push / leave) +daemon.py — 主循环,协调检测与同步 +``` + +## 检测方式 + +| Agent | 检测方法 | 状态判断 | +|-------|---------|---------| +| Claude Code | `pgrep -x claude` | 进程存在 → writing | +| Codex | `pgrep -x codex` | 进程存在 → writing | +| OpenClaw | `gateway.log` 修改时间 | 120s 内修改 → writing,否则 idle | + +## 后台运行 + +```bash +# macOS launchd +nohup python scripts/auto_detect/daemon.py > /tmp/star-office-daemon.log 2>&1 & + +# 或直接用 screen/tmux +screen -dmS star-office python scripts/auto_detect/daemon.py +``` + +## 相关 + +- Issue: [#65](https://github.com/ringhyacinth/Star-Office-UI/issues/65) +- PR: [#64](https://github.com/ringhyacinth/Star-Office-UI/pull/64) diff --git a/scripts/auto_detect/__init__.py b/scripts/auto_detect/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/auto_detect/client.py b/scripts/auto_detect/client.py new file mode 100644 index 0000000..9f83a6d --- /dev/null +++ b/scripts/auto_detect/client.py @@ -0,0 +1,74 @@ +""" +Star Office API client — handles join / push / leave calls. +""" + +import json +import urllib.request +import urllib.error +from typing import Optional + + +class OfficeClient: + """Lightweight HTTP client for Star Office backend API.""" + + def __init__(self, office_url: str, join_key: str, timeout: int = 5): + self.office_url = office_url.rstrip("/") + self.join_key = join_key + self.timeout = timeout + + def _post(self, endpoint: str, data: dict) -> dict: + try: + req = urllib.request.Request( + f"{self.office_url}{endpoint}", + data=json.dumps(data).encode("utf-8"), + headers={"Content-Type": "application/json"}, + ) + resp = urllib.request.urlopen(req, timeout=self.timeout) + return json.loads(resp.read()) + except urllib.error.HTTPError as e: + try: + body = json.loads(e.read()) + except Exception: + body = {"error": str(e)} + return body + except Exception as e: + return {"error": str(e)} + + def join(self, name: str, state: str = "idle", detail: str = "") -> Optional[str]: + """Join the office. Returns agentId on success, None on failure.""" + payload = { + "name": name, + "joinKey": self.join_key, + "state": state, + "detail": detail, + } + result = self._post("/join-agent", payload) + return result.get("agentId") + + def push(self, agent_id: str, state: str, detail: str = "", name: str = "") -> bool: + """Push agent state update. Returns True on success.""" + payload = { + "agentId": agent_id, + "joinKey": self.join_key, + "state": state, + "detail": detail, + } + if name: + payload["name"] = name + result = self._post("/agent-push", payload) + return result.get("ok", False) + + def set_state(self, state: str, detail: str = "") -> bool: + """Set the main agent (Star) state via /set_state.""" + payload = {"state": state, "detail": detail} + result = self._post("/set_state", payload) + return result.get("ok", False) or result.get("status") == "ok" + + def leave(self, agent_id: str) -> bool: + """Leave the office. Returns True on success.""" + payload = { + "agentId": agent_id, + "joinKey": self.join_key, + } + result = self._post("/leave-agent", payload) + return result.get("ok", False) diff --git a/scripts/auto_detect/daemon.py b/scripts/auto_detect/daemon.py new file mode 100644 index 0000000..58ed238 --- /dev/null +++ b/scripts/auto_detect/daemon.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python3 +""" +Star Office Auto-Detect Daemon + +Polls for running Claude Code / Codex / OpenClaw processes every N seconds, +auto-joins new instances to the office, pushes state updates, and removes +agents when their process exits. + +Usage: + python daemon.py # use defaults + python daemon.py --interval 15 # poll every 15s + python daemon.py --url http://host:28791 # custom office URL + +Environment variables: + STAR_OFFICE_URL Office backend URL (default: http://127.0.0.1:28791) + STAR_OFFICE_JOIN_KEY Join key (default: ocj_example_team_01) +""" + +import argparse +import hashlib +import json +import os +import signal +import sys +import time +from typing import Dict, List + +try: + from detector import DetectedAgent, detect_all + from client import OfficeClient +except ImportError: + from .detector import DetectedAgent, detect_all + from .client import OfficeClient + + +_state_file_path: str = "" + + +def _make_state_file(url: str, key: str) -> str: + """Generate a unique state file path per url+key combination.""" + token = hashlib.md5(f"{url}|{key}".encode()).hexdigest()[:8] + return f"/tmp/star-office-daemon-{token}.json" + + +def load_tracked() -> Dict[str, dict]: + """Load tracked agents from state file.""" + try: + with open(_state_file_path, "r") as f: + return json.load(f) + except Exception: + return {} + + +def save_tracked(tracked: Dict[str, dict]): + """Persist tracked agents to state file (atomic write).""" + tmp_path = _state_file_path + ".tmp" + with open(tmp_path, "w") as f: + json.dump(tracked, f, indent=2) + os.replace(tmp_path, _state_file_path) + + +def sync_agents(client: OfficeClient, tracked: Dict[str, dict], detected: List[DetectedAgent]) -> Dict[str, dict]: + """Sync detected agents with the office backend. + + - Join new agents + - Push state updates for existing agents + - Leave agents whose processes have exited + """ + active_keys = set() + + for agent in detected: + active_keys.add(agent.key) + + # Main agent (e.g. Enterprise Lobster) — drive the main sprite directly + if agent.is_main: + ok = client.set_state(agent.state, detail=agent.detail) + if ok: + tracked[agent.key] = {"name": agent.name, "is_main": True, "persistent": True} + else: + print(f"[set_state-failed] {agent.name}") + continue + + if agent.key not in tracked: + # New agent — join the office + agent_id = client.join(agent.name, state=agent.state, detail=agent.detail) + if agent_id: + tracked[agent.key] = { + "agentId": agent_id, + "name": agent.name, + } + print(f"[join] {agent.name} -> {agent_id}") + else: + print(f"[join-failed] {agent.name}") + continue + + # Push current state + entry = tracked[agent.key] + if not entry.get("agentId"): + # Corrupted entry without agentId — force re-join next cycle + del tracked[agent.key] + continue + ok = client.push(entry["agentId"], agent.state, detail=agent.detail, name=agent.name) + if ok: + entry["name"] = agent.name + entry["_fail_count"] = 0 + else: + fail_count = entry.get("_fail_count", 0) + 1 + entry["_fail_count"] = fail_count + print(f"[push-failed] {agent.name} ({entry['agentId']}) attempt {fail_count}") + # After 3 consecutive failures, drop stale agentId so next cycle re-joins + if fail_count >= 3: + print(f"[re-join] dropping stale agentId for {agent.name}") + del tracked[agent.key] + + # Remove agents that are no longer detected + gone_keys = [k for k in tracked if k not in active_keys] + for key in gone_keys: + entry = tracked[key] + if entry.get("is_main"): + client.set_state("idle", detail="agent no longer detected") + print(f"[idle] {entry['name']} (main, no longer detected)") + else: + agent_id = entry.get("agentId") + if agent_id: + client.leave(agent_id) + print(f"[leave] {entry['name']} ({agent_id})") + del tracked[key] + + return tracked + + +def main(): + parser = argparse.ArgumentParser(description="Star Office Auto-Detect Daemon") + parser.add_argument( + "--url", + default=os.environ.get("STAR_OFFICE_URL", "http://127.0.0.1:28791"), + help="Office backend URL", + ) + parser.add_argument( + "--key", + default=os.environ.get("STAR_OFFICE_JOIN_KEY", "ocj_example_team_01"), + help="Join key", + ) + parser.add_argument( + "--interval", + type=int, + default=int(os.environ.get("STAR_OFFICE_INTERVAL", "10")), + help="Poll interval in seconds (default: 10)", + ) + parser.add_argument( + "--busy-threshold", + type=int, + default=120, + help="OpenClaw busy threshold in seconds (default: 120)", + ) + args = parser.parse_args() + + global _state_file_path + _state_file_path = _make_state_file(args.url, args.key) + + client = OfficeClient(args.url, args.key) + tracked = load_tracked() + + print(f"Star Office Auto-Detect Daemon started") + print(f" URL: {args.url}") + print(f" Key: {args.key[:8]}...") + print(f" Interval: {args.interval}s") + + running = True + + def shutdown(signum, frame): + nonlocal running + running = False + + signal.signal(signal.SIGINT, shutdown) + signal.signal(signal.SIGTERM, shutdown) + + while running: + try: + detected = detect_all(busy_threshold=args.busy_threshold) + tracked = sync_agents(client, tracked, detected) + save_tracked(tracked) + except Exception as e: + print(f"[error] {e}") + + # Sleep in small increments so we can exit quickly + for _ in range(args.interval * 2): + if not running: + break + time.sleep(0.5) + + # Graceful cleanup + print("Shutting down, removing tracked agents...") + for key, entry in list(tracked.items()): + if entry.get("is_main"): + client.set_state("idle", detail="daemon stopped") + print(f"[idle] {entry['name']} (main)") + elif entry.get("agentId"): + client.leave(entry["agentId"]) + print(f"[leave] {entry['name']}") + save_tracked({}) + print("Done.") + + +if __name__ == "__main__": + main() diff --git a/scripts/auto_detect/detector.py b/scripts/auto_detect/detector.py new file mode 100644 index 0000000..72078d9 --- /dev/null +++ b/scripts/auto_detect/detector.py @@ -0,0 +1,167 @@ +""" +Agent process detector — discovers running Claude Code / Codex / OpenClaw instances. + +Each detector returns a list of DetectedAgent with a stable key, display name, and state. +""" + +import os +import subprocess +import time +from dataclasses import dataclass +from typing import List + + +@dataclass +class DetectedAgent: + """A detected agent process on the local machine.""" + key: str # stable identifier, e.g. "claude_12345" or "openclaw_personal" + name: str # display name, e.g. "Claude Code (trading)" + state: str # "writing" | "idle" + detail: str = "" # extra info (e.g. project name) + is_main: bool = False # if True, sync to main sprite via /set_state instead of agent system + + +def _get_pids(process_name: str) -> List[str]: + """Get PIDs for a given process name. + + Tries pgrep first, falls back to ps+awk (more reliable on macOS where + pgrep sometimes can't see processes due to SIP/sandbox restrictions). + Returns PIDs sorted numerically for deterministic ordering. + """ + pid_set = set() + + # Source 1: pgrep (matches process name directly) + try: + result = subprocess.run( + ["pgrep", "-x", process_name], + capture_output=True, text=True, timeout=5, + ) + for p in result.stdout.strip().split("\n"): + if p.strip(): + pid_set.add(p.strip()) + except Exception: + pass + + # Source 2: ps + basename match (catches cases pgrep misses, e.g. + # macOS where COMM is the full binary path like /Users/.../codex) + try: + result = subprocess.run( + ["ps", "-eo", "pid,comm"], + capture_output=True, text=True, timeout=5, + ) + for line in result.stdout.strip().split("\n"): + parts = line.strip().split(None, 1) + if len(parts) == 2 and os.path.basename(parts[1]) == process_name: + pid_set.add(parts[0]) + except Exception: + pass + + pids = list(pid_set) + + # Sort numerically so disambiguation labels are stable across cycles + pids.sort(key=lambda p: int(p)) + return pids + + +def _get_cwd_for_pid(pid: str) -> str: + """Get the current working directory for a PID (macOS/Linux).""" + try: + result = subprocess.run( + ["lsof", "-a", "-d", "cwd", "-p", pid, "-Fn"], + capture_output=True, text=True, timeout=5, + ) + for line in result.stdout.split("\n"): + if line.startswith("n/"): + return line[1:] + except Exception: + pass + return "" + + +def _basename_or_empty(path: str) -> str: + return os.path.basename(path) if path else "" + + +def _detect_by_process(process_name: str, label_prefix: str) -> List[DetectedAgent]: + """Detect running processes by name and return agents with disambiguated labels. + + Shared logic for Claude Code, Codex, and similar process-based agents. + """ + agents = [] + seen_labels = {} + + for pid in _get_pids(process_name): + cwd = _get_cwd_for_pid(pid) + label = _basename_or_empty(cwd) + + # Disambiguate when multiple processes share the same cwd + seen_labels[label] = seen_labels.get(label, 0) + 1 + if seen_labels[label] > 1: + label_display = f"{label}#{seen_labels[label]}" if label else f"#{seen_labels[label]}" + else: + label_display = label + + name = f"{label_prefix} ({label_display})" if label_display else label_prefix + agents.append(DetectedAgent( + key=f"{process_name}_{pid}", + name=name, + state="writing", + detail=label, + )) + + return agents + + +def detect_claude_code() -> List[DetectedAgent]: + """Detect running Claude Code processes.""" + return _detect_by_process("claude", "Claude Code") + + +def detect_codex() -> List[DetectedAgent]: + """Detect running Codex processes.""" + return _detect_by_process("codex", "Codex") + + +def _log_modified_within(logfile: str, threshold_seconds: int = 120) -> bool: + """Check if a log file was modified within the given threshold.""" + try: + if not os.path.isfile(logfile): + return False + mtime = os.path.getmtime(logfile) + return (time.time() - mtime) < threshold_seconds + except Exception: + return False + + +def detect_openclaw(busy_threshold: int = 120) -> List[DetectedAgent]: + """Detect OpenClaw activity by checking gateway log modification time.""" + agents = [] + home = os.path.expanduser("~") + + variants = [ + ("openclaw_personal", "Lobster Personal", os.path.join(home, ".openclaw", "logs", "gateway.log"), False), + ("openclaw_enterprise", "Lobster Enterprise", os.path.join(home, ".openclaw-enterprise", "logs", "gateway.log"), True), + ] + + for key, name, logfile, main in variants: + if not os.path.isfile(logfile): + continue + busy = _log_modified_within(logfile, busy_threshold) + agents.append(DetectedAgent( + key=key, + name=name, + state="writing" if busy else "idle", + detail="active" if busy else "ready", + is_main=main, + )) + + return agents + + +def detect_all(busy_threshold: int = 120) -> List[DetectedAgent]: + """Run all detectors and return combined list.""" + agents = [] + agents.extend(detect_claude_code()) + agents.extend(detect_codex()) + agents.extend(detect_openclaw(busy_threshold)) + return agents