"""`harbor doctor`: is the project healthy, and is every agent's app really connected? Each check is {area, check, status (ok|warn|fail|info), detail, fix}. Host checks read the host configuration files that `harbor install` writes; "live" checks use what Harbor recorded when agents last reached it. """ import json import os import re import tomllib import urllib.request from pathlib import Path from . import install def _check(area, check, status, detail="", fix=""): return {"area": area, "check": check, "status": status, "detail": detail, "fix": fix} def _text(path): try: return Path(path).read_text(encoding="utf-8") except OSError: return None def _block(text, start=install.START, end=install.END): return bool(text) and start in text and end in text def codex_checks(agent): home = install.user_home() / ".codex" out = [] config = _text(home / "config.toml") server = None try: server = tomllib.loads(config or "").get("mcp_servers", {}).get("harbor") except tomllib.TOMLDecodeError as error: out.append(_check("Codex", "config.toml parses", "fail", str(error), "fix the TOML by hand")) if server and server.get("env", {}).get("HARBOR_AGENT") == agent: out.append(_check("Codex", "MCP server", "ok", f"[mcp_servers.harbor] as {agent}")) else: out.append(_check("Codex", "MCP server", "fail", "missing or for another agent", "harbor install codex")) hooks = _text(home / "hooks.json") events = set() if hooks: try: events = {e for e, entries in json.loads(hooks).get("hooks", {}).items() if any(install._is_ours(x) for x in entries)} except ValueError: pass wanted = {"SessionStart", "UserPromptSubmit", "PreToolUse", "Stop"} out.append(_check("Codex", "hooks", "ok" if wanted <= events else "fail", ", ".join(sorted(events)) or "none", "" if wanted <= events else "harbor install codex")) trusted = len(re.findall(r"trusted_hash", config or "")) out.append(_check("Codex", "hooks trusted", "ok" if trusted else "warn", f"{trusted} trusted hook entr{'y' if trusted == 1 else 'ies'} in config.toml", "" if trusted else "Codex: Settings > Hooks > trust the Harbor hooks")) out.append(_check("Codex", "AGENTS.md", "ok" if _block(_text(home / "AGENTS.md"), install.MD_START, install.MD_END) else "warn", str(home / "AGENTS.md"), "harbor install codex")) return out def opencode_checks(agent): home = install.user_home() / ".config" / "opencode" out = [] config = next((p for p in (home / "opencode.jsonc", home / "opencode.json") if p.exists()), None) try: server = json.loads(_text(config) or "{}").get("mcp", {}).get("harbor") if config else None except ValueError: server = None ok = bool(server) and server.get("environment", {}).get("HARBOR_AGENT") == agent and server.get("enabled", True) out.append(_check("OpenCode", "MCP server", "ok" if ok else "fail", str(config or home), "" if ok else "harbor install opencode")) plugin = _text(home / "plugin" / "harbor.js") ok = bool(plugin) and f'const AGENT = "{agent}";' in plugin out.append(_check("OpenCode", "plugin", "ok" if ok else "fail", str(home / "plugin" / "harbor.js"), "" if ok else "harbor install opencode")) out.append(_check("OpenCode", "AGENTS.md", "ok" if _block(_text(home / "AGENTS.md"), install.MD_START, install.MD_END) else "warn", "", "harbor install opencode")) return out def deepseek_checks(agent): from . import projects home = install.user_home() / ".dsh" out = [] patch = _text(home / "profiles" / "desktop" / "cordis.patch.yml") rows = [] if patch: try: for row in install._TolerantLoader.load(patch): if isinstance(row, dict) and "insert" in row: rows += [r for r in row["insert"] if isinstance(r, dict)] except Exception as error: # noqa: BLE001 - report any YAML problem out.append(_check("DeepSeek", "cordis.patch.yml parses", "fail", str(error), "restore it from the backup")) mcp = next((r for r in rows if r.get("id") == "mcp-harbor"), None) ok = bool(mcp) and (mcp.get("config") or {}).get("env", {}).get("HARBOR_AGENT") == agent out.append(_check("DeepSeek", "MCP row", "ok" if ok else "fail", "mcp-harbor", "" if ok else "harbor install deepseek")) hooks_row = next((r for r in rows if r.get("id") == "hooks-harbor"), None) hooks_file = projects.harbor_home() / "integrations" / "deepseek" / "hooks.json" ok = bool(hooks_row) and hooks_file.exists() out.append(_check("DeepSeek", "hooks row and file", "ok" if ok else "fail", str(hooks_file), "" if ok else "harbor install deepseek")) out.append(_check("DeepSeek", "AGENTS.md", "ok" if _block(_text(home / "AGENTS.md"), install.MD_START, install.MD_END) else "warn", "", "harbor install deepseek")) return out def kimi_checks(agent, project): from . import projects out = [] name = f"harbor-{project.name}" folder = projects.harbor_home() / "integrations" / "kimi" / name manifest = _text(folder / "kimi.plugin.json") ok = bool(manifest) and f'"HARBOR_AGENT": "{agent}"' in manifest out.append(_check("Kimi", "plugin folder", "ok" if ok else "fail", str(folder), "" if ok else f"harbor --project {project.name} install kimi")) share = install.kimi_share() / "daimon" registered = (share / "plugin-market" / "personal" / f"{name}.json").exists() out.append(_check("Kimi", "registered", "ok" if registered else "fail", "Personal market", "" if registered else f"harbor --project {project.name} install kimi")) installed, enabled = False, False record = share / "runtime" / "kimi-code" / "home" / "plugins" / "installed.json" try: def walk(node): if isinstance(node, dict): if node.get("id") == name: yield node for value in node.values(): yield from walk(value) elif isinstance(node, list): for value in node: yield from walk(value) entry = next(walk(json.loads(_text(record) or "{}")), None) installed, enabled = entry is not None, bool(entry and entry.get("enabled", True)) except ValueError: pass installed = installed or (share / "plugin-packages" / name).exists() out.append(_check("Kimi", "installed", "ok" if installed and enabled else "warn", f"{name} " + ("enabled" if enabled else "installed but disabled" if installed else "not installed"), "" if installed and enabled else "Kimi: sidebar Plugins > Personal > install (and enable) Harbor")) return out def running_mcp_servers(): """{agent: count} of Harbor MCP server processes running right now (started by the agents' apps).""" import subprocess try: out = subprocess.run(["powershell", "-NoProfile", "-Command", "Get-CimInstance Win32_Process -Filter \"Name='python.exe'\" | " "ForEach-Object { $_.CommandLine }"], capture_output=True, text=True, timeout=20).stdout except (OSError, subprocess.SubprocessError): return {} found = {} for line in out.splitlines(): m = re.search(r"(?:run|harbor_mcp|harbor)\.py.*?--as\s+(\w+)\s+mcp\b", line) or ( re.search(r"harbor_mcp\.py", line) and None) if m: found[m.group(1)] = found.get(m.group(1), 0) + 1 return found HOST_CHECKS = {"codex": codex_checks, "opencode": opencode_checks, "deepseek": deepseek_checks} def server_check(project, port, server_known): url = f"http://127.0.0.1:{port}" if server_known: return _check("Server", url, "ok", "this page is served by it") try: with urllib.request.urlopen(f"{url}/api/projects", timeout=2) as response: names = [p["name"] for p in json.loads(response.read())] except OSError: return _check("Server", url, "warn", "not running (agents still work)", "start it: harbor serve, or Chat.cmd") except (ValueError, KeyError, TypeError): return _check("Server", url, "warn", "something else answers on this port", "free the port or pick another") ok = project.name in names return _check("Server", url, "ok" if ok else "warn", "serving " + ", ".join(names), "" if ok else "restart the server") def run(project, harbor, port=None, server_known=False): """server_known: the caller is the server itself (asking it over HTTP would wait on its own lock).""" port = port or int(os.environ.get("HARBOR_PORT", "8766")) checks = [_check("Project", "file", "ok", str(project.source)), _check("Project", "roster", "ok", ", ".join(project.names))] try: quick = harbor.conn.execute("PRAGMA quick_check").fetchone()[0] count = harbor.conn.execute("SELECT COUNT(*) FROM messages").fetchone()[0] checks.append(_check("Database", "integrity", "ok" if quick == "ok" else "fail", f"{project.db} ({count} messages): {quick}", "" if quick == "ok" else "restore a backup")) except Exception as error: # noqa: BLE001 checks.append(_check("Database", "integrity", "fail", str(error), "restore a backup")) backups = sorted(project.backups.glob("harbor-*.db")) if project.backups.exists() else [] if backups: age = (harbor.clock() - backups[-1].stat().st_mtime) / 3600 checks.append(_check("Database", "backups", "ok" if age < 24 else "warn", f"{len(backups)} copies, newest {age:.1f} h old", "" if age < 24 else "harbor backup")) else: checks.append(_check("Database", "backups", "warn", "none yet", "harbor backup (the server also makes them)")) if project.mirror: try: with open(project.mirror, "ab"): pass checks.append(_check("Mirror", "writable", "ok", str(project.mirror))) except OSError as error: checks.append(_check("Mirror", "writable", "fail", str(error), "check the file's permissions")) checks.append(server_check(project, port, server_known)) links = harbor.connections() servers = running_mcp_servers() installed = {} # app -> the agent its Harbor install is for (the first one) for spec in project.agents: if spec["kind"] != "human" and spec.get("host"): installed.setdefault(spec["host"], spec["name"]) for spec in project.agents: if spec["kind"] == "human": continue host = spec.get("host") as_agent = installed.get(host, spec["name"]) if host in HOST_CHECKS: checks += HOST_CHECKS[host](as_agent) elif host == "kimi": checks += kimi_checks(as_agent, project) if as_agent != spec["name"]: # a second session of the same app: its folder names it ok = bool(spec.get("workdir")) and project.identity_for(spec["workdir"]) == spec["name"] checks.append(_check(spec["name"], "session identity", "ok" if ok else "fail", f"shares the {host} install with {as_agent}; its folder {spec.get('workdir') or '?'} " + ("names it in [identities]" if ok else "is not in [identities]"), "" if ok else "map its worktree folder to it in [identities], or have it call i_am")) seen = {(r["channel"], r["host"]): r for r in links.get(spec["name"], [])} live = ", ".join(f"{ch}{('/' + h) if h else ''} {r['minutes']} min ago" for (ch, h), r in sorted(seen.items())) running = servers.get(spec["name"], 0) if running: live = f"Harbor tools running in its app now ({running} process{'es' if running > 1 else ''})" + ( f"; {live}" if live else "") status = "ok" if running or any(ch in ("mcp", "hook") for ch, _ in seen) else "info" checks.append(_check(spec["name"], "live connection", status, live or "not seen through MCP or hooks yet", "" if status == "ok" else "shows once the app starts a session with Harbor installed")) return checks def render(checks): mark = {"ok": "OK ", "warn": "WARN", "fail": "FAIL", "info": "info"} lines = [] for c in checks: lines.append(f"{mark[c['status']]} {c['area']:<9} {c['check']:<22} {c['detail']}") if c["fix"] and c["status"] in ("warn", "fail"): lines.append(f"{'':6}{'':<9} -> {c['fix']}") bad = sum(c["status"] == "fail" for c in checks) warn = sum(c["status"] == "warn" for c in checks) lines.append(f"\n{bad} failing, {warn} warnings.") return "\n".join(lines)