diff --git a/backend/app.py b/backend/app.py index f64c6757..9bf5a290 100644 --- a/backend/app.py +++ b/backend/app.py @@ -67,6 +67,19 @@ _last_home_rotate_at = 0 ASSET_DEFAULTS_FILE = os.path.join(ROOT_DIR, "asset-defaults.json") RUNTIME_CONFIG_FILE = os.path.join(ROOT_DIR, "runtime-config.json") +AUTO_DETECT_CONSENT_VERSION = 1 +AUTO_DETECT_MAX_AGENTS = int(os.getenv("STAR_OFFICE_AUTO_DETECT_MAX_AGENTS", "8")) +AUTO_DETECT_SOURCE = "local-auto-detect" +STAR_OFFICE_LOG_DIR = os.getenv("STAR_OFFICE_LOG_DIR", "/tmp/star-office-ui") +AUTO_DETECT_DAEMON_PID_FILE = os.path.join(STAR_OFFICE_LOG_DIR, "auto-detect.pid") +AUTO_DETECT_DAEMON_LOG_FILE = os.path.join(STAR_OFFICE_LOG_DIR, "auto-detect.log") +AUTO_DETECT_DAEMON_SCRIPT = os.path.join(ROOT_DIR, "scripts", "auto_detect", "daemon.py") +AUTO_DETECT_JOIN_KEY = os.getenv("STAR_OFFICE_JOIN_KEY", "ocj_example_team_01") +AUTO_DETECT_PYTHON = ( + os.path.join(ROOT_DIR, ".venv", "bin", "python") + if os.path.exists(os.path.join(ROOT_DIR, ".venv", "bin", "python")) + else sys.executable +) # Canonical agent states: single source of truth for validation and mapping VALID_AGENT_STATES = frozenset({"idle", "writing", "researching", "executing", "syncing", "error"}) @@ -354,6 +367,125 @@ def save_runtime_config(data): _store_save_runtime_config(RUNTIME_CONFIG_FILE, data) +def is_auto_detect_enabled() -> bool: + cfg = load_runtime_config() + return bool(cfg.get("auto_detect_enabled")) + + +def _normalize_agent_source(value: str) -> str: + raw = (value or "").strip().lower() + if raw in {AUTO_DETECT_SOURCE, "auto-detect", "local_auto_detect"}: + return AUTO_DETECT_SOURCE + if raw: + return raw + return "" + + +def is_auto_detect_agent(agent: dict) -> bool: + if not isinstance(agent, dict) or agent.get("isMain"): + return False + source = (agent.get("source") or "").strip().lower() + return source == AUTO_DETECT_SOURCE + + +def strip_auto_detect_agents(agents: list[dict]) -> tuple[list[dict], list[dict]]: + kept = [] + removed = [] + for agent in agents: + if is_auto_detect_agent(agent): + removed.append(agent) + continue + kept.append(agent) + return kept, removed + + +def _pid_is_running(pid: int | None) -> bool: + if not pid: + return False + try: + os.kill(int(pid), 0) + return True + except Exception: + return False + + +def _read_pid_file(path: str) -> int | None: + try: + with open(path, "r", encoding="utf-8") as f: + return int((f.read() or "").strip()) + except Exception: + return None + + +def _get_backend_port() -> int: + try: + port = int(os.environ.get("STAR_BACKEND_PORT", "19000")) + except Exception: + port = 19000 + return port if port > 0 else 19000 + + +def ensure_auto_detect_daemon_running() -> tuple[bool, str]: + """Start the local auto-detect daemon if it is not already running.""" + if not os.path.exists(AUTO_DETECT_DAEMON_SCRIPT): + return False, "auto-detect daemon script not found" + + os.makedirs(STAR_OFFICE_LOG_DIR, exist_ok=True) + + existing_pid = _read_pid_file(AUTO_DETECT_DAEMON_PID_FILE) + if _pid_is_running(existing_pid): + return True, "already-running" + + if existing_pid: + try: + os.remove(AUTO_DETECT_DAEMON_PID_FILE) + except Exception: + pass + + env = os.environ.copy() + env["STAR_OFFICE_URL"] = f"http://127.0.0.1:{_get_backend_port()}" + env["STAR_OFFICE_JOIN_KEY"] = AUTO_DETECT_JOIN_KEY + + try: + with open(AUTO_DETECT_DAEMON_LOG_FILE, "a", encoding="utf-8") as log_fp: + proc = subprocess.Popen( + [AUTO_DETECT_PYTHON, AUTO_DETECT_DAEMON_SCRIPT], + cwd=ROOT_DIR, + env=env, + stdin=subprocess.DEVNULL, + stdout=log_fp, + stderr=subprocess.STDOUT, + start_new_session=True, + ) + with open(AUTO_DETECT_DAEMON_PID_FILE, "w", encoding="utf-8") as f: + f.write(str(proc.pid)) + return True, f"started:{proc.pid}" + except Exception as e: + return False, str(e) + + +def get_auto_detect_daemon_state(enabled: bool) -> tuple[bool, bool, str]: + """Return (daemon_alive, daemon_running, daemon_status). + + daemon_running = process alive (factual, never lies). + daemon_status = functional state: + - disabled: not enabled, daemon not running + - waiting-consent: not enabled, but daemon alive in background + - running: enabled and daemon alive (actively syncing) + - stopped: enabled but daemon not running + """ + daemon_alive = _pid_is_running(_read_pid_file(AUTO_DETECT_DAEMON_PID_FILE)) + if enabled and daemon_alive: + status = "running" + elif enabled and not daemon_alive: + status = "stopped" + elif not enabled and daemon_alive: + status = "waiting-consent" + else: + status = "disabled" + return daemon_alive, daemon_alive, status + + def _ensure_home_favorites_index(): os.makedirs(HOME_FAVORITES_DIR, exist_ok=True) if not os.path.exists(HOME_FAVORITES_INDEX_FILE): @@ -834,11 +966,20 @@ def state_to_area(state): except Exception: pass +# Best-effort daemon recovery on backend start without relying on config GET side effects. +try: + if is_auto_detect_enabled(): + ensure_auto_detect_daemon_running() +except Exception: + pass + @app.route("/agents", methods=["GET"]) def get_agents(): """Get full agents list (for multi-agent UI), with auto-cleanup on access""" agents = load_agents_state() + if not is_auto_detect_enabled(): + agents, _removed = strip_auto_detect_agents(agents) now = datetime.now() cleaned_agents = [] @@ -962,10 +1103,20 @@ def join_agent(): state = data.get("state", "idle") detail = data.get("detail", "") join_key = data.get("joinKey", "").strip() + agent_source = _normalize_agent_source(data.get("source") or "") # Normalize state early for compatibility state = normalize_agent_state(state) + if agent_source == AUTO_DETECT_SOURCE and not is_auto_detect_enabled(): + return jsonify({"ok": False, "msg": "本机状态自动检测默认关闭,需用户先在界面中明确启用"}), 403 + + if agent_source == AUTO_DETECT_SOURCE: + current_agents = load_agents_state() + auto_count = sum(1 for a in current_agents if is_auto_detect_agent(a) and a.get("name") != name) + if auto_count >= AUTO_DETECT_MAX_AGENTS: + return jsonify({"ok": False, "msg": f"本机自动检测 agent 已达上限 ({AUTO_DETECT_MAX_AGENTS})"}), 429 + if not join_key: return jsonify({"ok": False, "msg": "请提供接入密钥"}), 400 @@ -1047,7 +1198,7 @@ def _age_seconds(dt_str): existing["detail"] = detail existing["updated_at"] = datetime.now().isoformat() existing["area"] = state_to_area(state) - existing["source"] = "remote-openclaw" + existing["source"] = agent_source or existing.get("source") or "remote-openclaw" existing["joinKey"] = join_key existing["authStatus"] = "approved" existing["authApprovedAt"] = datetime.now().isoformat() @@ -1070,7 +1221,7 @@ def _age_seconds(dt_str): "detail": detail, "updated_at": datetime.now().isoformat(), "area": state_to_area(state), - "source": "remote-openclaw", + "source": agent_source or "remote-openclaw", "joinKey": join_key, "authStatus": "approved", "authApprovedAt": datetime.now().isoformat(), @@ -1175,6 +1326,7 @@ def agent_push(): state = (data.get("state") or "").strip() detail = (data.get("detail") or "").strip() name = (data.get("name") or "").strip() + agent_source = _normalize_agent_source(data.get("source") or "") if not agent_id or not join_key or not state: return jsonify({"ok": False, "msg": "缺少 agentId/joinKey/state"}), 400 @@ -1202,6 +1354,9 @@ def agent_push(): if not target: return jsonify({"ok": False, "msg": "agent 未注册,请先 join"}), 404 + if (agent_source == AUTO_DETECT_SOURCE or is_auto_detect_agent(target)) and not is_auto_detect_enabled(): + return jsonify({"ok": False, "msg": "本机状态自动检测默认关闭,需用户先在界面中明确启用"}), 403 + # Auth check: only approved agents can push. # Note: "offline" is a presence state (stale), not a revoked authorization. # Allow offline agents to resume pushing and auto-promote them back to approved. @@ -1222,7 +1377,7 @@ def agent_push(): target["name"] = name target["updated_at"] = datetime.now().isoformat() target["area"] = state_to_area(state) - target["source"] = "remote-openclaw" + target["source"] = agent_source or target.get("source") or "remote-openclaw" target["lastPushAt"] = datetime.now().isoformat() save_agents_state(agents) @@ -1833,6 +1988,83 @@ def gemini_config_get(): return jsonify({"ok": False, "msg": str(e)}), 500 +@app.route("/config/auto-detect", methods=["GET"]) +def auto_detect_config_get(): + try: + cfg = load_runtime_config() + enabled = bool(cfg.get("auto_detect_enabled")) + _daemon_alive, daemon_running, daemon_status = get_auto_detect_daemon_state(enabled) + agents = load_agents_state() + auto_count = sum(1 for a in agents if is_auto_detect_agent(a)) + return jsonify({ + "ok": True, + "enabled": enabled, + "granted_at": cfg.get("auto_detect_consent_granted_at"), + "consent_version": int(cfg.get("auto_detect_consent_version") or AUTO_DETECT_CONSENT_VERSION), + "daemon_running": daemon_running, + "daemon_status": daemon_status, + "agent_count": auto_count, + "agent_max": AUTO_DETECT_MAX_AGENTS, + }) + except Exception as e: + return jsonify({"ok": False, "msg": str(e)}), 500 + + +@app.route("/config/auto-detect", methods=["POST"]) +def auto_detect_config_set(): + guard = _require_asset_editor_auth() + if guard: + return guard + try: + data = request.get_json(silent=True) or {} + raw_enabled = data.get("enabled") + if raw_enabled is None: + return jsonify({"ok": False, "msg": "缺少 enabled"}), 400 + + if isinstance(raw_enabled, bool): + enabled = raw_enabled + elif isinstance(raw_enabled, (int, float)): + enabled = raw_enabled != 0 + else: + enabled = str(raw_enabled).strip().lower() in {"1", "true", "yes", "on"} + + if enabled and not bool(data.get("confirm")): + return jsonify({"ok": False, "msg": "启用前需要用户明确确认授权"}), 400 + + payload = { + "auto_detect_enabled": enabled, + "auto_detect_consent_version": AUTO_DETECT_CONSENT_VERSION, + "auto_detect_consent_granted_at": datetime.now().isoformat() if enabled else None, + } + save_runtime_config(payload) + if enabled: + ensure_auto_detect_daemon_running() + cleared_agents = 0 + if not enabled: + agents = load_agents_state() + cleaned_agents, removed_agents = strip_auto_detect_agents(agents) + if len(cleaned_agents) != len(agents): + save_agents_state(cleaned_agents) + cleared_agents = len(removed_agents) + agents = load_agents_state() + auto_count = sum(1 for a in agents if is_auto_detect_agent(a)) + _daemon_alive, daemon_running, daemon_status = get_auto_detect_daemon_state(enabled) + return jsonify({ + "ok": True, + "enabled": enabled, + "granted_at": payload["auto_detect_consent_granted_at"], + "consent_version": AUTO_DETECT_CONSENT_VERSION, + "daemon_running": daemon_running, + "daemon_status": daemon_status, + "agent_count": auto_count, + "agent_max": AUTO_DETECT_MAX_AGENTS, + "cleared_agents": cleared_agents, + "msg": "Auto-detect 已启用" if enabled else "Auto-detect 已关闭", + }) + except Exception as e: + return jsonify({"ok": False, "msg": str(e)}), 500 + + @app.route("/config/gemini", methods=["POST"]) def gemini_config_set(): guard = _require_asset_editor_auth() @@ -2100,4 +2332,3 @@ def assets_upload(): print("=" * 50) app.run(host="0.0.0.0", port=backend_port, debug=False) - diff --git a/backend/store_utils.py b/backend/store_utils.py index 2a0e3405..9c9e9fd9 100644 --- a/backend/store_utils.py +++ b/backend/store_utils.py @@ -10,6 +10,16 @@ import os +def _normalize_bool(value, default: bool = False) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, (int, float)): + return value != 0 + if isinstance(value, str): + return value.strip().lower() in {"1", "true", "yes", "on"} + return default + + def _load_json(path: str): """Load JSON from a file; caller handles missing file or parse errors.""" with open(path, "r", encoding="utf-8") as f: @@ -86,17 +96,31 @@ def _normalize_user_model(model_name: str) -> str: def load_runtime_config(path: str) -> dict: - """Load runtime config (gemini_api_key, gemini_model) from env and optional JSON file.""" + """Load runtime config from env and optional JSON file.""" base = { "gemini_api_key": os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY") or "", "gemini_model": _normalize_user_model(os.getenv("GEMINI_MODEL") or "nanobanana-pro"), + "auto_detect_enabled": _normalize_bool(os.getenv("STAR_OFFICE_AUTO_DETECT_ENABLED"), False), + "auto_detect_consent_granted_at": None, + "auto_detect_consent_version": 1, } if os.path.exists(path): try: data = _load_json(path) if isinstance(data, dict): - base.update({k: data.get(k, base.get(k)) for k in ["gemini_api_key", "gemini_model"]}) + base.update({ + k: data.get(k, base.get(k)) + for k in [ + "gemini_api_key", + "gemini_model", + "auto_detect_enabled", + "auto_detect_consent_granted_at", + "auto_detect_consent_version", + ] + }) base["gemini_model"] = _normalize_user_model(base.get("gemini_model") or "nanobanana-pro") + base["auto_detect_enabled"] = _normalize_bool(base.get("auto_detect_enabled"), False) + base["auto_detect_consent_version"] = int(base.get("auto_detect_consent_version") or 1) except Exception: pass return base diff --git a/frontend/btn-auto-detect-sprite.png b/frontend/btn-auto-detect-sprite.png new file mode 100644 index 00000000..889865b6 Binary files /dev/null and b/frontend/btn-auto-detect-sprite.png differ diff --git a/frontend/electron-standalone.html b/frontend/electron-standalone.html index a07cd341..3ca6583b 100644 --- a/frontend/electron-standalone.html +++ b/frontend/electron-standalone.html @@ -471,6 +471,67 @@ width: 0%; transition: width 0.3s ease; } + #auto-detect-disable-popup-title:empty { + display: none; + } + + #auto-detect-disable-popup { + position: fixed; + z-index: 100020; + width: 240px; + border: 4px solid #0e1119; + background: #141722; + font-family: 'ArkPixel', monospace; + padding: 14px; + color: #e5e7eb; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); + display: grid; + gap: 10px; + image-rendering: pixelated; + } + #auto-detect-disable-popup[hidden] { + display: none !important; + } + #auto-detect-disable-popup-title { + color: #ffd700; + font-family: 'ArkPixel', monospace; + font-size: 13px; + font-weight: bold; + letter-spacing: 2px; + } + #auto-detect-disable-popup-body { + color: #9ca3af; + font-family: 'ArkPixel', monospace; + font-size: 11px; + line-height: 1.5; + } + #auto-detect-disable-popup-actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 6px; + } + #btn-disable-confirm { + min-height: 30px; + background: #5a1818; + border: 2px solid #e94560; + color: #fecaca; + font-family: 'ArkPixel', monospace; + font-size: 11px; + } + #btn-disable-confirm:hover { + background: #6a2828; + } + #btn-disable-cancel { + min-height: 30px; + background: #3a3f4f; + border: 2px solid #555; + color: #9ca3af; + font-family: 'ArkPixel', monospace; + font-size: 11px; + } + #btn-disable-cancel:hover { + color: #e5e7eb; + } #status-text { position: absolute; bottom: 12px; @@ -588,15 +649,81 @@ left bottom, left bottom, right bottom, right bottom; } + #control-bar-header { + position: relative; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 6px 10px 10px; + } #control-bar-title { color: #ffd700; font-size: 16px; font-weight: bold; text-align: center; letter-spacing: 1px; - padding: 6px 0 10px; + padding: 0; border-bottom: 0; } + /* 本机检测入口:像素精灵按钮,钢蓝色调示意"可选";亮=on / 暗=off */ + #control-bar #btn-auto-detect-entry { + position: absolute; + right: 8px; + top: 6px; + width: 76px; + height: 42px; + min-height: auto !important; + background-image: url('/static/btn-auto-detect-sprite.png') !important; + background-color: transparent !important; + background-repeat: no-repeat !important; + background-size: 300% 100% !important; + background-position: 0 0 !important; + border: none !important; + border-radius: 0 !important; + box-shadow: none !important; + appearance: none; + -webkit-appearance: none; + image-rendering: pixelated; + color: #c8d8e8; + font-family: 'ArkPixel', monospace; + font-size: 10px; + font-weight: 400; + line-height: 1; + letter-spacing: 0.3px; + text-shadow: 0 1px 0 rgba(30, 50, 80, 0.5); + padding: 0 6px 7px !important; + cursor: pointer; + transition: padding-top .08s ease, padding-bottom .08s ease, filter .12s ease; + } + /* off = 暗淡但可读 */ + #control-bar #btn-auto-detect-entry.is-off { + filter: brightness(0.75) saturate(0.5); + color: #3a4a5a; + text-shadow: 0 1px 0 rgba(150, 170, 200, 0.3); + } + #control-bar #btn-auto-detect-entry:hover { + background-color: transparent !important; + border: none !important; + filter: brightness(1.1); + } + #control-bar #btn-auto-detect-entry:active { + background-position: 50% 0 !important; + padding-top: 4px !important; + padding-bottom: 0 !important; + filter: brightness(0.92); + } + /* on = 亮绿 */ + #control-bar #btn-auto-detect-entry.is-enabled { + filter: brightness(1.15) saturate(1.2); + color: #b8f060; + text-shadow: 0 1px 0 rgba(40, 80, 10, 0.6); + } + #control-bar #btn-auto-detect-entry.is-syncing { + filter: brightness(1.0); + color: #f0d860; + text-shadow: 0 1px 0 rgba(80, 60, 10, 0.5); + } #control-buttons { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); @@ -606,6 +733,7 @@ padding-left: 10px; padding-right: 10px; box-sizing: border-box; + flex: 0 0 auto; } #btn-open-drawer { grid-column: 1 / -1; @@ -1029,10 +1157,11 @@ appearance: none; -webkit-appearance: none; image-rendering: pixelated; - color: #5e6366 !important; - font-weight: 400 !important; - font-size: 15px !important; - text-shadow: none !important; + color: #4a2a08 !important; + font-weight: 700 !important; + font-size: 16px !important; + letter-spacing: .4px; + text-shadow: 0 1px 0 rgba(255, 244, 214, 0.5) !important; padding: 0 10px 10px !important; line-height: 1 !important; transition: padding-top .08s ease, padding-bottom .08s ease, filter .12s ease; @@ -1426,7 +1555,7 @@ border-color: #faf4cf; background: rgba(237, 214, 144, 0.96); } - .panel-toggle-title { + body.desktop-shell .panel-toggle-title { cursor: pointer; user-select: none; } @@ -1508,6 +1637,16 @@ #memo-title { font-size: 14px; } + #control-bar-header { + gap: 6px; + padding-bottom: 8px; + } + #btn-auto-detect-entry { + right: 8px; + top: 3px; + font-size: 9px; + padding: 3px 7px; + } #control-buttons button, #control-bar button, @@ -1537,7 +1676,7 @@ gap: 8px; } - #status-text { + #status-text { bottom: 8px; left: 8px; max-width: 64vw; @@ -1606,7 +1745,15 @@
- + +
加载中...
@@ -1632,7 +1779,10 @@
-
Star 状态
+
+
Star 状态
+ +
@@ -1640,6 +1790,7 @@
+
@@ -1786,6 +1937,19 @@ brokerDone: '✅ 已按中介方案生成并替换底图,正在刷新房间...', moveSuccess: '✅ 搬家成功!', brokerMissingKey: '❌ 生图失败:缺少 GEMINI API Key,请在下方填写并保存后重试', + autoDetectEntryOff: '检测', + autoDetectEntrySyncing: '检测', + autoDetectEntryOn: '检测', + autoDetectOverlayTitle: '本机同步(可选)', + autoDetectOverlayBody: '检测 Claude Code、Codex、OpenClaw 状态并同步到房间。', + autoDetectOverlayEnable: '允许并开启', + autoDetectLater: '跳过', + autoDetectDisableTitle: '关闭本机同步', + autoDetectDisableBody: '本机助手将从房间消失。', + autoDetectDisableConfirm: '确认关闭', + autoDetectDisableCancel: '取消', + autoDetectSaving: '保存中…', + autoDetectAuthRequired: '请先在装修房间中完成验证,再管理本机同步', geminiPanelTitle: '🔐 API 设置(可折叠)', geminiHint: '可选:填写你的生图 API Key(留空不影响基础功能)', geminiApiDoc: '📘 如何申请 Google API Key', geminiInputPh: '粘贴 GEMINI_API_KEY(不会回显)', geminiSaveKey: '保存 Key', geminiMaskNoKey: '当前状态:未配置 Key', geminiMaskHasKey: '当前已配置:', speedModeLabel: '生成模式', speedFast: '🍌2', speedQuality: '🍌Pro', searchPlaceholder: '搜索资产名(如 desk / sofa / star)', loaded: '已加载', allAssets: '全部资产', @@ -1820,6 +1984,19 @@ brokerDone: '✅ Broker plan applied and background replaced, refreshing room...', moveSuccess: '✅ Move successful!', brokerMissingKey: '❌ Generation failed: missing GEMINI API key. Fill it below and retry.', + autoDetectEntryOff: 'Detect', + autoDetectEntrySyncing: 'Detect', + autoDetectEntryOn: 'Detect', + autoDetectOverlayTitle: 'Local Sync (Optional)', + autoDetectOverlayBody: 'Detect local Claude Code, Codex, and OpenClaw status and sync to your room. Turn off anytime.', + autoDetectOverlayEnable: 'Allow and Enable', + autoDetectLater: 'Skip for Now', + autoDetectDisableTitle: 'Disable Local Sync', + autoDetectDisableBody: 'Local agents will leave the room.', + autoDetectDisableConfirm: 'Confirm Disable', + autoDetectDisableCancel: 'Cancel', + autoDetectSaving: 'Saving…', + autoDetectAuthRequired: 'Unlock Decor first before managing Local Sync', geminiPanelTitle: '🔐 API Settings (collapsible)', geminiHint: 'Optional: set your image API key (base features work without it)', geminiApiDoc: '📘 How to get a Google API Key', geminiInputPh: 'Paste GEMINI_API_KEY (input hidden)', geminiSaveKey: 'Save Key', geminiMaskNoKey: 'Current: no key configured', geminiMaskHasKey: 'Configured key:', speedModeLabel: 'Render Mode', speedFast: '🍌2', speedQuality: '🍌Pro', searchPlaceholder: 'Search assets (desk / sofa / star)', loaded: 'Loaded', allAssets: 'All Assets', @@ -1854,6 +2031,19 @@ brokerDone: '✅ 仲介プランを適用して背景を更新しました。部屋を更新中...', moveSuccess: '✅ 引っ越し成功!', brokerMissingKey: '❌ 生成失敗:GEMINI APIキーが未設定です。下で入力して保存してください。', + autoDetectEntryOff: '検出', + autoDetectEntrySyncing: '検出', + autoDetectEntryOn: '検出', + autoDetectOverlayTitle: 'ローカル同期(任意)', + autoDetectOverlayBody: '初期状態は OFF で、ローカルの Claude Code / Codex / OpenClaw 状態は読み取りません。「許可して有効化」を押した後にだけ、Star Office が同期を開始します。', + autoDetectOverlayEnable: '許可して有効化', + autoDetectLater: 'あとで決める', + autoDetectDisableTitle: 'ローカル同期を停止', + autoDetectDisableBody: 'ローカルのエージェントは部屋から退出します。', + autoDetectDisableConfirm: '停止する', + autoDetectDisableCancel: 'キャンセル', + autoDetectSaving: '許可設定を保存しています...', + autoDetectAuthRequired: '先に部屋編集の認証を完了してから、ローカル同期を管理してください', geminiPanelTitle: '🔐 API設定(折りたたみ)', geminiHint: '任意:画像生成APIキーを設定(未設定でも基本機能は利用可)', geminiApiDoc: '📘 Google API Keyの取得方法', geminiInputPh: 'GEMINI_API_KEY を貼り付け(入力は非表示)', geminiSaveKey: 'Keyを保存', geminiMaskNoKey: '現在:キー未設定', geminiMaskHasKey: '設定済みキー:', speedModeLabel: '生成モード', speedFast: '🍌2', speedQuality: '🍌Pro', searchPlaceholder: 'アセット検索(desk / sofa / star)', loaded: '読み込み済み', allAssets: '全アセット', @@ -2057,6 +2247,7 @@ } ensureMemoBgVisible(); renderBootLoadingText(Number(loadingProgressBar?.style?.width?.replace('%','') || 0)); + renderAutoDetectConsent(); syncDesktopUiLanguage(); } @@ -3773,6 +3964,191 @@ } } catch (e) {} } + function mergeAutoDetectConsentState(patch = {}) { + const current = window.autoDetectConsent || {}; + window.autoDetectConsent = { + enabled: false, + grantedAt: null, + syncing: false, + agentCount: 0, + agentMax: 8, + ...current, + ...patch, + }; + return window.autoDetectConsent; + } + + function showAutoDetectFeedback(message) { + if (!message) return; + try { + window.alert(message); + } catch (e) {} + } + + function renderAutoDetectConsent() { + const state = window.autoDetectConsent || { enabled: false, grantedAt: null }; + const entryBtn = document.getElementById('btn-auto-detect-entry'); + const isSyncing = !!state.syncing; + + if (entryBtn) { + entryBtn.textContent = isSyncing + ? t('autoDetectEntrySyncing') + : state.enabled + ? t('autoDetectEntryOn') + : t('autoDetectEntryOff'); + entryBtn.classList.toggle('is-enabled', !!state.enabled); + entryBtn.classList.toggle('is-syncing', isSyncing); + } + } + + + + function openAutoDetectOverlay() { + const state = window.autoDetectConsent || { enabled: false }; + if (state.enabled) { + showSmallPopup('disable'); + } else { + showSmallPopup('enable'); + } + } + function showSmallPopup(mode) { + const popup = document.getElementById('auto-detect-disable-popup'); + const entryBtn = document.getElementById('btn-auto-detect-entry'); + if (!popup || !entryBtn) return; + + const titleEl = document.getElementById('auto-detect-disable-popup-title'); + const bodyEl = document.getElementById('auto-detect-disable-popup-body'); + const confirmBtn = document.getElementById('btn-disable-confirm'); + const cancelBtn = document.getElementById('btn-disable-cancel'); + + if (mode === 'enable') { + if (titleEl) titleEl.textContent = t('autoDetectOverlayTitle'); + if (bodyEl) bodyEl.textContent = t('autoDetectOverlayBody'); + if (confirmBtn) { + confirmBtn.textContent = t('autoDetectOverlayEnable'); + confirmBtn.style.background = '#78a340'; + confirmBtn.style.borderColor = '#8fbe4a'; + confirmBtn.style.color = '#f3ffe6'; + confirmBtn.onclick = function() { setAutoDetectConsent(true, true); hideSmallPopup(); }; + } + if (cancelBtn) { + cancelBtn.textContent = t('autoDetectLater'); + cancelBtn.onclick = function() { hideSmallPopup(); }; + } + } else { + if (titleEl) titleEl.textContent = t('autoDetectDisableTitle'); + if (bodyEl) bodyEl.textContent = t('autoDetectDisableBody'); + if (confirmBtn) { + confirmBtn.textContent = t('autoDetectDisableConfirm'); + confirmBtn.style.background = '#5a1818'; + confirmBtn.style.borderColor = '#e94560'; + confirmBtn.style.color = '#fecaca'; + confirmBtn.onclick = function() { setAutoDetectConsent(false, true); hideSmallPopup(); }; + } + if (cancelBtn) { + cancelBtn.textContent = t('autoDetectDisableCancel'); + cancelBtn.onclick = function() { hideSmallPopup(); }; + } + } + + popup.hidden = false; + const rect = entryBtn.getBoundingClientRect(); + // 固定锚定在按钮正下方,右对齐 + let top = rect.bottom + 4; + let left = rect.right - 240; + if (left < 8) left = 8; + if (top + 180 > window.innerHeight) top = rect.top - 180; + popup.style.top = Math.round(top) + 'px'; + popup.style.left = Math.round(left) + 'px'; + } + + function hideSmallPopup() { + const popup = document.getElementById('auto-detect-disable-popup'); + if (popup) popup.hidden = true; + } + + window.addEventListener('scroll', hideSmallPopup, true); + window.addEventListener('resize', hideSmallPopup); + + document.addEventListener('click', (event) => { + const popup = document.getElementById('auto-detect-disable-popup'); + const entryBtn = document.getElementById('btn-auto-detect-entry'); + if (!popup || popup.hidden) return; + if (popup.contains(event.target) || (entryBtn && entryBtn.contains(event.target))) return; + hideSmallPopup(); + }); + + + + + async function ensureAutoDetectConsentLoaded() { + try { + const res = await fetch('/config/auto-detect', { cache: 'no-store' }); + const data = await res.json(); + if (data && data.ok) { + mergeAutoDetectConsentState({ + enabled: !!data.enabled, + grantedAt: data.granted_at || null, + syncing: false, + agentCount: data.agent_count || 0, + agentMax: data.agent_max || 8, + }); + } + } catch (e) {} + renderAutoDetectConsent(); + } + + async function setAutoDetectConsent(enabled, dismissOverlay = false) { + renderAutoDetectConsent(t('autoDetectSaving')); + try { + const res = await fetch('/config/auto-detect', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled: !!enabled, confirm: !!enabled }) + }); + const data = await res.json(); + if (!data.ok) { + const msg = data.code === 'UNAUTHORIZED' ? t('autoDetectAuthRequired') : (data.msg || String(res.status)); + renderAutoDetectConsent(); + showAutoDetectFeedback(msg); + return; + } + mergeAutoDetectConsentState({ + enabled: !!data.enabled, + grantedAt: data.granted_at || null, + syncing: !!enabled, + agentCount: Math.max(0, Number(data.agent_count) || 0), + agentMax: Math.max(1, Number(data.agent_max) || 8), + }); + if (dismissOverlay) { + hideSmallPopup(); + } + renderAutoDetectConsent(''); + if (enabled) { + const finalizeSyncing = () => { + if (window.autoDetectConsent && window.autoDetectConsent.enabled) { + window.autoDetectConsent.syncing = false; + renderAutoDetectConsent(''); + } + }; + if (typeof fetchGuestAgents === 'function') { + const poll = () => Promise.resolve(fetchGuestAgents()).catch(() => {}); + poll(); + for (const ms of [1500, 4000, 7000, 10000]) { + setTimeout(poll, ms); + } + setTimeout(() => { poll().finally(finalizeSyncing); }, 13000); + } else { + setTimeout(finalizeSyncing, 13000); + } + } else if (typeof fetchGuestAgents === 'function') { + fetchGuestAgents(); + } + } catch (e) { + renderAutoDetectConsent(); + showAutoDetectFeedback(String(e)); + } + } async function saveGeminiConfigFromUI() { const input = document.getElementById('gemini-api-key-input'); @@ -4380,13 +4756,11 @@ } function getAreaRect(area) { - // 区域坐标(海辛提供,左上-右下;这里的 x/y 作为 sprite 底部锚点坐标来用) - // 休息区域范围(511,262)(841,621) - // 工作区域范围(190,526)(380,683) - // error 区域范围(932,275)(1109,327) + // 区域坐标(左上-右下;x/y 作为 sprite 底部锚点坐标) + // 点位避开家具,仅在地板空旷区域生成 const rects = { - breakroom: { x1: 511, y1: 262, x2: 841, y2: 621 }, - writing: { x1: 190, y1: 526, x2: 380, y2: 683 }, + breakroom: { x1: 550, y1: 580, x2: 860, y2: 665 }, + writing: { x1: 200, y1: 590, x2: 440, y2: 680 }, error: { x1: 932, y1: 275, x2: 1109, y2: 327 } }; return rects[area] || rects.breakroom; @@ -4402,21 +4776,37 @@ function getAreaPoint(area, idx) { // 非 demo 访客:仍用固定点位,避免每次轮询都抖动。 + // 点位需避开家具:沙发(798,272)、咖啡机(659,397)、桌子(218,417)、植物(565,178)(977,496)、猫(94,557) const map = { breakroom: [ - { x: 511, y: 262 }, - { x: 841, y: 621 }, - { x: 690, y: 470 } + { x: 590, y: 600 }, + { x: 680, y: 585 }, + { x: 770, y: 605 }, + { x: 840, y: 590 }, + { x: 620, y: 650 }, + { x: 720, y: 640 }, + { x: 810, y: 655 }, + { x: 660, y: 660 } ], writing: [ - { x: 190, y: 526 }, - { x: 380, y: 683 }, - { x: 300, y: 610 } + { x: 220, y: 600 }, + { x: 310, y: 620 }, + { x: 400, y: 600 }, + { x: 260, y: 650 }, + { x: 350, y: 670 }, + { x: 430, y: 640 }, + { x: 240, y: 630 }, + { x: 380, y: 660 } ], error: [ { x: 932, y: 275 }, { x: 1109, y: 327 }, - { x: 1020, y: 305 } + { x: 1020, y: 305 }, + { x: 960, y: 340 }, + { x: 1070, y: 280 }, + { x: 990, y: 260 }, + { x: 1050, y: 350 }, + { x: 940, y: 310 } ] }; const arr = map[area] || map.breakroom; @@ -4838,6 +5228,7 @@ // 启动 Phaser 游戏 new Phaser.Game(config); setTimeout(async () => { + await ensureAutoDetectConsentLoaded(); try { const authRes = await fetch('/assets/auth/status', { cache: 'no-store' }); const authData = await authRes.json(); diff --git a/frontend/index.html b/frontend/index.html index b8034282..11bf33df 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -127,6 +127,67 @@ width: 0%; transition: width 0.3s ease; } + #auto-detect-disable-popup-title:empty { + display: none; + } + + #auto-detect-disable-popup { + position: fixed; + z-index: 100020; + width: 160px; + border: 4px solid #0e1119; + background: #141722; + font-family: 'ArkPixel', monospace; + padding: 14px; + color: #e5e7eb; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); + display: grid; + gap: 10px; + image-rendering: pixelated; + } + #auto-detect-disable-popup[hidden] { + display: none !important; + } + #auto-detect-disable-popup-title { + color: #ffd700; + font-family: 'ArkPixel', monospace; + font-size: 13px; + font-weight: bold; + letter-spacing: 2px; + } + #auto-detect-disable-popup-body { + color: #9ca3af; + font-family: 'ArkPixel', monospace; + font-size: 11px; + line-height: 1.5; + } + #auto-detect-disable-popup-actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 6px; + } + #btn-disable-confirm { + min-height: 30px; + background: #5a1818; + border: 2px solid #e94560; + color: #fecaca; + font-family: 'ArkPixel', monospace; + font-size: 11px; + } + #btn-disable-confirm:hover { + background: #6a2828; + } + #btn-disable-cancel { + min-height: 30px; + background: #3a3f4f; + border: 2px solid #555; + color: #9ca3af; + font-family: 'ArkPixel', monospace; + font-size: 11px; + } + #btn-disable-cancel:hover { + color: #e5e7eb; + } #status-text { position: absolute; bottom: 12px; @@ -206,15 +267,88 @@ left bottom, left bottom, right bottom, right bottom; } + #control-bar-header { + position: relative; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 6px 10px 16px; + } #control-bar-title { color: #ffd700; font-size: 16px; font-weight: bold; text-align: center; letter-spacing: 1px; - padding: 6px 0 10px; + padding: 0; border-bottom: 0; } + /* 本机检测入口:像素精灵按钮,钢蓝色调示意"可选";亮=on / 暗=off */ + #control-bar #btn-auto-detect-entry { + position: absolute; + right: 8px; + top: 6px; + width: auto; + min-width: 56px; + height: 36px; + min-height: auto !important; + background-image: url('/static/btn-auto-detect-sprite.png') !important; + background-color: transparent !important; + background-repeat: no-repeat !important; + background-size: 300% 100% !important; + background-position: 0 0 !important; + border: none !important; + border-radius: 0 !important; + box-shadow: none !important; + appearance: none; + -webkit-appearance: none; + image-rendering: pixelated; + color: #c8d8e8; + font-family: 'ArkPixel', monospace; + font-size: 10px; + font-weight: 400; + line-height: 1; + letter-spacing: 0.3px; + text-shadow: 0 1px 0 rgba(30, 50, 80, 0.5); + padding: 0 6px 7px !important; + cursor: pointer; + transition: padding-top .08s ease, padding-bottom .08s ease, filter .12s ease; + } + /* off = 暗淡但可读 */ + #control-bar #btn-auto-detect-entry.is-off { + filter: brightness(0.75) saturate(0.5); + color: #3a4a5a; + text-shadow: 0 1px 0 rgba(150, 170, 200, 0.3); + } + #control-bar #btn-auto-detect-entry:hover { + background-color: transparent !important; + border: none !important; + filter: brightness(1.1); + } + #control-bar #btn-auto-detect-entry:active { + background-position: 50% 0 !important; + padding-top: 4px !important; + padding-bottom: 0 !important; + filter: brightness(0.92); + } + /* on = 正常亮度,柔和暖白文字 */ + #control-bar #btn-auto-detect-entry.is-enabled { + filter: brightness(1.05); + color: #e8e0d0; + text-shadow: 0 1px 0 rgba(40, 50, 60, 0.5); + } + /* 检测中 = 呼吸闪烁 */ + #control-bar #btn-auto-detect-entry.is-syncing { + filter: brightness(1.0); + color: #e0d8c0; + text-shadow: 0 1px 0 rgba(60, 50, 20, 0.5); + animation: detect-pulse 1.5s ease-in-out infinite; + } + @keyframes detect-pulse { + 0%, 100% { filter: brightness(0.85); } + 50% { filter: brightness(1.15); } + } #control-buttons { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); @@ -224,6 +358,7 @@ padding-left: 10px; padding-right: 10px; box-sizing: border-box; + flex: 0 0 auto; } #btn-open-drawer { grid-column: 1 / -1; @@ -697,7 +832,7 @@ font-weight: 400 !important; font-size: 15px !important; text-shadow: none !important; - padding: 0 10px 10px !important; + padding: 0 8px 9px; line-height: 1 !important; transition: padding-top .08s ease, padding-bottom .08s ease, filter .12s ease; } @@ -1032,6 +1167,18 @@ #memo-title { font-size: 14px; } + #control-bar-header { + gap: 6px; + padding-bottom: 8px; + } + #control-bar #btn-auto-detect-entry { + right: 6px; + top: 2px; + width: 60px; + height: 34px; + font-size: 9px; + padding: 0 4px 6px !important; + } #control-buttons button, #control-bar button, @@ -1048,7 +1195,6 @@ padding: 4px 2px; font-size: 11px; } - .guest-agent-item { align-items: flex-start; gap: 10px; @@ -1061,7 +1207,7 @@ gap: 8px; } - #status-text { + #status-text { bottom: 8px; left: 8px; max-width: 64vw; @@ -1138,7 +1284,14 @@
- +
正在进入像素办公室…
@@ -1160,7 +1313,10 @@
-
Star 状态
+
+
Star 状态
+ +
@@ -1168,6 +1324,7 @@
+
@@ -1310,6 +1467,18 @@ brokerDone: '✅ 已按中介方案生成并替换底图,正在刷新房间...', moveSuccess: '✅ 搬家成功!', brokerMissingKey: '❌ 生图失败:缺少 GEMINI API Key,请在下方填写并保存后重试', + autoDetectEntryOff: '检测关闭', + autoDetectEntrySyncing: '检测中...', + autoDetectEntryOn: '检测开启', + autoDetectOverlayBody: '(可选)检测Claude Code/Codex状态', + autoDetectOverlayEnable: '允许并开启', + autoDetectLater: '跳过', + autoDetectDisableBody: '关闭检测,AI助手们将离开房间', + autoDetectDisableConfirm: '确认关闭', + autoDetectDisableCancel: '取消', + autoDetectSaving: '保存中…', + autoDetectAuthRequired: '请先在装修房间中完成验证,再管理本机同步', + autoDetectLimitReached: '本机同步已达上限({current}/{max}),更多进程不再接入', geminiPanelTitle: '🔐 API 设置(可折叠)', geminiHint: '可选:填写你的生图 API Key(留空不影响基础功能)', geminiApiDoc: '📘 如何申请 Google API Key', geminiInputPh: '粘贴 GEMINI_API_KEY(不会回显)', geminiSaveKey: '保存 Key', geminiMaskNoKey: '当前状态:未配置 Key', geminiMaskHasKey: '当前已配置:', speedModeLabel: '生成模式', speedFast: '🍌2', speedQuality: '🍌Pro', searchPlaceholder: '搜索资产名(如 desk / sofa / star)', loaded: '已加载', allAssets: '全部资产', @@ -1338,6 +1507,18 @@ brokerDone: '✅ Broker plan applied and background replaced, refreshing room...', moveSuccess: '✅ Move successful!', brokerMissingKey: '❌ Generation failed: missing GEMINI API key. Fill it below and retry.', + autoDetectEntryOff: 'Detect off', + autoDetectEntrySyncing: 'Detecting...', + autoDetectEntryOn: 'Detect on', + autoDetectOverlayBody: '(Optional) Detect Claude Code/Codex status', + autoDetectOverlayEnable: 'Allow and Enable', + autoDetectLater: 'Skip for Now', + autoDetectDisableBody: 'AI agents will leave the room', + autoDetectDisableConfirm: 'Confirm Disable', + autoDetectDisableCancel: 'Cancel', + autoDetectSaving: 'Saving…', + autoDetectAuthRequired: 'Unlock Decor first before managing Local Sync', + autoDetectLimitReached: 'Local sync at capacity ({current}/{max}), additional agents won\'t be added', geminiPanelTitle: '🔐 API Settings (collapsible)', geminiHint: 'Optional: set your image API key (base features work without it)', geminiApiDoc: '📘 How to get a Google API Key', geminiInputPh: 'Paste GEMINI_API_KEY (input hidden)', geminiSaveKey: 'Save Key', geminiMaskNoKey: 'Current: no key configured', geminiMaskHasKey: 'Configured key:', speedModeLabel: 'Render Mode', speedFast: '🍌2', speedQuality: '🍌Pro', searchPlaceholder: 'Search assets (desk / sofa / star)', loaded: 'Loaded', allAssets: 'All Assets', @@ -1366,6 +1547,18 @@ brokerDone: '✅ 仲介プランを適用して背景を更新しました。部屋を更新中...', moveSuccess: '✅ 引っ越し成功!', brokerMissingKey: '❌ 生成失敗:GEMINI APIキーが未設定です。下で入力して保存してください。', + autoDetectEntryOff: '検出オフ', + autoDetectEntrySyncing: '検出中...', + autoDetectEntryOn: '検出オン', + autoDetectOverlayBody: '(任意)Claude Code/Codex状態検出', + autoDetectOverlayEnable: '許可して有効化', + autoDetectLater: 'あとで決める', + autoDetectDisableBody: 'ローカルのエージェントは部屋から退出します。', + autoDetectDisableConfirm: '停止する', + autoDetectDisableCancel: 'キャンセル', + autoDetectSaving: '許可設定を保存しています...', + autoDetectAuthRequired: '先に部屋編集の認証を完了してから、ローカル同期を管理してください', + autoDetectLimitReached: 'ローカル同期が上限に達しました({current}/{max})。追加エージェントは接続されません', geminiPanelTitle: '🔐 API設定(折りたたみ)', geminiHint: '任意:画像生成APIキーを設定(未設定でも基本機能は利用可)', geminiApiDoc: '📘 Google API Keyの取得方法', geminiInputPh: 'GEMINI_API_KEY を貼り付け(入力は非表示)', geminiSaveKey: 'Keyを保存', geminiMaskNoKey: '現在:キー未設定', geminiMaskHasKey: '設定済みキー:', speedModeLabel: '生成モード', speedFast: '🍌2', speedQuality: '🍌Pro', searchPlaceholder: 'アセット検索(desk / sofa / star)', loaded: '読み込み済み', allAssets: '全アセット', @@ -1473,6 +1666,7 @@ } ensureMemoBgVisible(); renderBootLoadingText(Number(loadingProgressBar?.style?.width?.replace('%','') || 0)); + renderAutoDetectConsent(); } function setUILanguage(lang) { @@ -2873,6 +3067,217 @@ } catch (e) {} } + + + + + + + function mergeAutoDetectConsentState(patch = {}) { + const current = window.autoDetectConsent || {}; + window.autoDetectConsent = { + enabled: false, + grantedAt: null, + syncing: false, + agentCount: 0, + agentMax: 8, + ...current, + ...patch, + }; + return window.autoDetectConsent; + } + + function showAutoDetectFeedback(message) { + if (!message) return; + try { + window.alert(message); + } catch (e) {} + } + + function renderAutoDetectConsent() { + const state = window.autoDetectConsent || { enabled: false, grantedAt: null }; + const entryBtn = document.getElementById('btn-auto-detect-entry'); + const isSyncing = !!state.syncing; + + if (entryBtn) { + if (isSyncing) { + entryBtn.textContent = t('autoDetectEntrySyncing'); + } else if (state.enabled) { + entryBtn.textContent = t('autoDetectEntryOn'); + } else { + entryBtn.textContent = t('autoDetectEntryOff'); + } + entryBtn.classList.toggle('is-enabled', !!state.enabled && !isSyncing); + entryBtn.classList.toggle('is-syncing', isSyncing); + entryBtn.classList.toggle('is-off', !state.enabled && !isSyncing); + } + } + + function openAutoDetectOverlay() { + const state = window.autoDetectConsent || { enabled: false }; + if (state.enabled) { + setAutoDetectConsent(false, false); + } else { + showSmallPopup('enable'); + } + } + function showSmallPopup(mode) { + const popup = document.getElementById('auto-detect-disable-popup'); + const entryBtn = document.getElementById('btn-auto-detect-entry'); + if (!popup || !entryBtn) return; + + const titleEl = document.getElementById('auto-detect-disable-popup-title'); + const bodyEl = document.getElementById('auto-detect-disable-popup-body'); + const confirmBtn = document.getElementById('btn-disable-confirm'); + const cancelBtn = document.getElementById('btn-disable-cancel'); + + const state = window.autoDetectConsent || {}; + const agentCount = state.agentCount || 0; + const agentMax = state.agentMax || 8; + const limitHit = agentCount >= agentMax; + const limitNote = limitHit + ? '\n' + t('autoDetectLimitReached').replace('{current}', agentCount).replace('{max}', agentMax) + : ''; + + if (mode === 'enable') { + if (titleEl) titleEl.textContent = ''; + if (bodyEl) bodyEl.textContent = t('autoDetectOverlayBody'); + if (confirmBtn) { + confirmBtn.textContent = t('autoDetectOverlayEnable'); + confirmBtn.style.background = '#78a340'; + confirmBtn.style.borderColor = '#8fbe4a'; + confirmBtn.style.color = '#f3ffe6'; + confirmBtn.onclick = function() { setAutoDetectConsent(true, true); hideSmallPopup(); }; + } + if (cancelBtn) { + cancelBtn.textContent = t('autoDetectLater'); + cancelBtn.onclick = function() { hideSmallPopup(); }; + } + } else { + if (titleEl) titleEl.textContent = ''; + const bodyText = t('autoDetectDisableBody') + + (agentCount > 0 ? ` (${agentCount}/${agentMax})` : '') + + limitNote; + if (bodyEl) bodyEl.textContent = bodyText; + if (confirmBtn) { + confirmBtn.textContent = t('autoDetectDisableConfirm'); + confirmBtn.style.background = '#5a1818'; + confirmBtn.style.borderColor = '#e94560'; + confirmBtn.style.color = '#fecaca'; + confirmBtn.onclick = function() { setAutoDetectConsent(false, true); hideSmallPopup(); }; + } + if (cancelBtn) { + cancelBtn.textContent = t('autoDetectDisableCancel'); + cancelBtn.onclick = function() { hideSmallPopup(); }; + } + } + + popup.hidden = false; + _positionPopup(); + } + + function _positionPopup() { + const popup = document.getElementById('auto-detect-disable-popup'); + const btn = document.getElementById('btn-auto-detect-entry'); + if (!popup || popup.hidden || !btn) return; + const r = btn.getBoundingClientRect(); + popup.style.top = Math.round(r.top) + 'px'; + popup.style.left = Math.round(r.right + 6) + 'px'; + } + + window.addEventListener('scroll', _positionPopup, true); + window.addEventListener('resize', _positionPopup); + + function hideSmallPopup() { + const popup = document.getElementById('auto-detect-disable-popup'); + if (popup) popup.hidden = true; + } + + document.addEventListener('click', (event) => { + const popup = document.getElementById('auto-detect-disable-popup'); + const entryBtn = document.getElementById('btn-auto-detect-entry'); + if (!popup || popup.hidden) return; + if (popup.contains(event.target) || (entryBtn && entryBtn.contains(event.target))) return; + hideSmallPopup(); + }); + + + + + async function ensureAutoDetectConsentLoaded() { + try { + const res = await fetch('/config/auto-detect', { cache: 'no-store' }); + const data = await res.json(); + if (data && data.ok) { + mergeAutoDetectConsentState({ + enabled: !!data.enabled, + grantedAt: data.granted_at || null, + syncing: false, + agentCount: data.agent_count || 0, + agentMax: data.agent_max || 8, + }); + } + } catch (e) {} + renderAutoDetectConsent(); + } + + async function setAutoDetectConsent(enabled, dismissOverlay = false) { + renderAutoDetectConsent(t('autoDetectSaving')); + try { + const res = await fetch('/config/auto-detect', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled: !!enabled, confirm: !!enabled }) + }); + const data = await res.json(); + if (!data.ok) { + const msg = data.code === 'UNAUTHORIZED' ? t('autoDetectAuthRequired') : (data.msg || String(res.status)); + renderAutoDetectConsent(); + showAutoDetectFeedback(msg); + return; + } + mergeAutoDetectConsentState({ + enabled: !!data.enabled, + grantedAt: data.granted_at || null, + syncing: !!enabled, + agentCount: Math.max(0, Number(data.agent_count) || 0), + agentMax: Math.max(1, Number(data.agent_max) || 8), + }); + if (dismissOverlay) { + hideSmallPopup(); + } + renderAutoDetectConsent(''); + if (enabled) { + const finalizeSyncing = () => { + if (window.autoDetectConsent && window.autoDetectConsent.enabled) { + window.autoDetectConsent.syncing = false; + renderAutoDetectConsent(); + } + }; + // 轮询直到有 agent 出现再切换,最多等 20 秒兜底 + let resolved = false; + const pollAndCheck = async () => { + if (resolved) return; + if (typeof fetchGuestAgents === 'function') await Promise.resolve(fetchGuestAgents()).catch(() => {}); + const hasAgents = (guestAgents || []).some(a => !a.isMain && a.source === 'local-auto-detect'); + if (hasAgents) { + resolved = true; + finalizeSyncing(); + } + }; + for (const ms of [2000, 5000, 8000, 12000, 16000]) { + setTimeout(pollAndCheck, ms); + } + setTimeout(() => { if (!resolved) finalizeSyncing(); }, 20000); + } else if (typeof fetchGuestAgents === 'function') { + fetchGuestAgents(); + } + } catch (e) { + renderAutoDetectConsent(); + showAutoDetectFeedback(String(e)); + } + } + async function saveGeminiConfigFromUI() { const input = document.getElementById('gemini-api-key-input'); const msg = document.getElementById('gemini-config-msg'); @@ -3468,13 +3873,11 @@ } function getAreaRect(area) { - // 区域坐标(海辛提供,左上-右下;这里的 x/y 作为 sprite 底部锚点坐标来用) - // 休息区域范围(511,262)(841,621) - // 工作区域范围(190,526)(380,683) - // error 区域范围(932,275)(1109,327) + // 区域坐标(左上-右下;x/y 作为 sprite 底部锚点坐标) + // 点位避开家具,仅在地板空旷区域生成 const rects = { - breakroom: { x1: 511, y1: 262, x2: 841, y2: 621 }, - writing: { x1: 190, y1: 526, x2: 380, y2: 683 }, + breakroom: { x1: 550, y1: 580, x2: 860, y2: 665 }, + writing: { x1: 200, y1: 590, x2: 440, y2: 680 }, error: { x1: 932, y1: 275, x2: 1109, y2: 327 } }; return rects[area] || rects.breakroom; @@ -3490,26 +3893,27 @@ function getAreaPoint(area, idx) { // 非 demo 访客:仍用固定点位,避免每次轮询都抖动。 + // 点位需避开家具:沙发(798,272)、咖啡机(659,397)、桌子(218,417)、植物(565,178)(977,496)、猫(94,557) const map = { breakroom: [ - { x: 511, y: 262 }, - { x: 841, y: 621 }, - { x: 690, y: 470 }, - { x: 600, y: 340 }, - { x: 770, y: 540 }, - { x: 550, y: 420 }, - { x: 720, y: 310 }, - { x: 650, y: 580 } + { x: 590, y: 600 }, + { x: 680, y: 585 }, + { x: 770, y: 605 }, + { x: 840, y: 590 }, + { x: 620, y: 650 }, + { x: 720, y: 640 }, + { x: 810, y: 655 }, + { x: 660, y: 660 } ], writing: [ - { x: 190, y: 526 }, - { x: 380, y: 683 }, - { x: 300, y: 610 }, - { x: 240, y: 570 }, - { x: 350, y: 640 }, - { x: 160, y: 600 }, - { x: 420, y: 560 }, - { x: 280, y: 660 } + { x: 220, y: 600 }, + { x: 310, y: 620 }, + { x: 400, y: 600 }, + { x: 260, y: 650 }, + { x: 350, y: 670 }, + { x: 430, y: 640 }, + { x: 240, y: 630 }, + { x: 380, y: 660 } ], error: [ { x: 932, y: 275 }, @@ -3924,6 +4328,8 @@ // 非关键初始化延后到首屏之后,提升首开速度 setTimeout(async () => { + await ensureAutoDetectConsentLoaded(); + // 动态探测 flowers 精灵表帧规格(避免写死 65x65 导致显示比例异常) try { const res = await fetch('/assets/list?t=' + Date.now(), { cache: 'no-store' }); diff --git a/frontend/office_bg_small.webp b/frontend/office_bg_small.webp index eb584fbf..885d34b5 100644 Binary files a/frontend/office_bg_small.webp and b/frontend/office_bg_small.webp differ diff --git a/runtime-config.sample.json b/runtime-config.sample.json index 46ce024a..482115c3 100644 --- a/runtime-config.sample.json +++ b/runtime-config.sample.json @@ -1,4 +1,7 @@ { "gemini_api_key": "YOUR_GEMINI_API_KEY", - "gemini_model": "nanobanana-pro" + "gemini_model": "nanobanana-pro", + "auto_detect_enabled": false, + "auto_detect_consent_granted_at": null, + "auto_detect_consent_version": 1 } diff --git a/scripts/auto_detect/README.md b/scripts/auto_detect/README.md new file mode 100644 index 00000000..863f06ec --- /dev/null +++ b/scripts/auto_detect/README.md @@ -0,0 +1,59 @@ +# Star Office Auto-Detect Daemon + +自动检测本机运行的 Claude Code / Codex / OpenClaw 实例,并同步到 Star Office。 + +## 快速开始 + +```bash +# 先在 Star Office 页面里手动授权“本机状态自动检测” + +# 使用默认配置(localhost:19000) +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:19000` | 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 — 主循环,协调检测与同步 +``` + +## 检测方式 + +只有在用户在 Star Office UI 中明确授权后,daemon 才会开始读取这些本地状态。 + +| 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 00000000..e69de29b diff --git a/scripts/auto_detect/client.py b/scripts/auto_detect/client.py new file mode 100644 index 00000000..763819fc --- /dev/null +++ b/scripts/auto_detect/client.py @@ -0,0 +1,91 @@ +""" +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 _request(self, method: str, endpoint: str, data: dict | None = None) -> dict: + try: + payload = None if data is None else json.dumps(data).encode("utf-8") + headers = {} + if data is not None: + headers["Content-Type"] = "application/json" + req = urllib.request.Request( + f"{self.office_url}{endpoint}", + data=payload, + headers=headers, + method=method, + ) + 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 _post(self, endpoint: str, data: dict) -> dict: + return self._request("POST", endpoint, data) + + def _get(self, endpoint: str) -> dict: + return self._request("GET", endpoint) + + 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, + "source": "local-auto-detect", + } + 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, + "source": "local-auto-detect", + } + 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) + + def get_auto_detect_config(self) -> dict: + """Fetch whether local auto-detect is explicitly enabled by the user.""" + return self._get("/config/auto-detect") diff --git a/scripts/auto_detect/daemon.py b/scripts/auto_detect/daemon.py new file mode 100644 index 00000000..4ec67dfd --- /dev/null +++ b/scripts/auto_detect/daemon.py @@ -0,0 +1,249 @@ +#!/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:19000 # custom office URL + +Environment variables: + STAR_OFFICE_URL Office backend URL (default: http://127.0.0.1:19000) + STAR_OFFICE_JOIN_KEY Join key (default: ocj_example_team_01) + +Important: + Auto-detect stays disabled until the user explicitly enables it in + Star Office UI. Before consent is granted, this daemon will not read + local Claude Code / Codex / OpenClaw process state. +""" + +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 clear_tracked(client: OfficeClient, tracked: Dict[str, dict], reason: str) -> Dict[str, dict]: + """Remove all tracked agents from the office backend.""" + if not tracked: + return {} + + print(f"[auto-detect-disabled] clearing tracked agents: {reason}") + for key, entry in list(tracked.items()): + if entry.get("is_main"): + client.set_state("idle", detail=reason) + print(f"[idle] {entry['name']} (main)") + elif entry.get("agentId"): + client.leave(entry["agentId"]) + print(f"[leave] {entry['name']}") + del tracked[key] + return tracked + + +def is_auto_detect_enabled(client: OfficeClient) -> tuple[bool, str]: + """Check whether the user explicitly enabled local auto-detect.""" + result = client.get_auto_detect_config() + if result.get("ok"): + return bool(result.get("enabled")), "" + return False, result.get("msg") or result.get("error") or "auto-detect consent unavailable" + + +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:19000"), + 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") + print(f" Consent: required") + + running = True + waiting_for_consent = False + + def shutdown(signum, frame): + nonlocal running + running = False + + signal.signal(signal.SIGINT, shutdown) + signal.signal(signal.SIGTERM, shutdown) + + while running: + try: + enabled, reason = is_auto_detect_enabled(client) + if not enabled: + tracked = clear_tracked(client, tracked, reason or "user consent not granted") + save_tracked(tracked) + if not waiting_for_consent: + print("[consent-required] Auto-detect is off until the user enables it in Star Office UI.") + waiting_for_consent = True + else: + if waiting_for_consent: + print("[consent-granted] Auto-detect enabled. Starting local agent sync.") + waiting_for_consent = False + 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 00000000..2a722de3 --- /dev/null +++ b/scripts/auto_detect/detector.py @@ -0,0 +1,168 @@ +""" +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. + Process running = writing (these are interactive coding tools, not background daemons). + """ + 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