Skip to content

Commit 7854315

Browse files
ZD Studiosclaude
andcommitted
feat: self-healing watchdog, Claude login from the dashboard, claude-code diagnostics
Auto-heal: hub watchdog health-checks every agent. On a down agent it auto-restarts it (rate-limited, never starts services that were never up); if the restart fails it pulls the log tail and asks a healthy agent (opencode, else brain) to diagnose the root cause. Events at GET /api/health_events and in Status -> Self-healing log. Config: watchdog.enabled/interval. Hub-triggered restarts set AIOS_NO_UPDATE_CHECK=1 so a heal never triggers a git pull. Claude login in the dashboard: hub runs the Claude CLI login as an interactive session (pty on Linux/WSL, pipes on Windows), streams its output, extracts the authorize URL, and accepts the code back. Endpoints GET/POST /api/claude_login (start|input|stop). Settings now has a two-step flow with a live auth status line. claude-code fix: it was working all along — the auth probe used a 45s timeout while the claude CLI cold-starts slowly (it waits 3s on stdin), so it falsely reported 'not logged in'. Timeout 45s->180s, plus GET /api/claude_status reporting service_up/version/logged_in/model/error. Verified on Windows: killed crewai's real listener -> watchdog logged 'down', restarted it, logged 'healed', service back up. Login session spawns/streams/exits (2.1.202) and stops. claude_status returns logged_in:true, model claude-sonnet-4-5, and a precise error when claudecode is stopped. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 4cf0739 commit 7854315

5 files changed

Lines changed: 283 additions & 8 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,8 @@ Plus two integrations that glue it together:
167167
- **✦ Team** — one assistant that orchestrates the whole team: the Brain plans, delegates subtasks to the specialist agents, and synthesizes one answer (the practical "merge").
168168
- **Configure openclaw AND hermes fully inside the hub** — both control-UIs are embedded via frame-stripping proxies, so you get channels, **connectors**, model providers, **MCP servers**, skills, plugins, **automations/cron**, and sessions right in the hub (they normally block embedding).
169169
- **Automations** — schedule prompts to run against any agent every N minutes (daily digests, checks).
170-
- **Connect your Claude account (Pro/Max)** — first run `aios claude-login` (opens the browser OAuth for your subscription), then one click in **Settings → "Use my Claude subscription"** routes the Brain/Team/crews through **claude-code** (no API key, no per-token cost). The button tests a real completion and tells you if you still need to log in.
170+
- **Log in to Claude from the dashboard****Settings → "1 · Log in to Claude"** runs the Claude CLI login *through the hub*: it shows the authorize link, you approve in the browser and paste the code back, all in the UI. Then **"2 · Use my Claude subscription"** routes the Brain/Team/crews through **claude-code** (no API key, no per-token cost). A live status line shows whether claude-code is up and actually authenticated. (Terminal equivalent: `aios claude-login`.)
171+
- **🛡️ Self-healing agents** — a watchdog in the hub health-checks every agent. If one stops responding it's **automatically restarted**; if the restart fails, a **healthy agent reads its logs and diagnoses the cause**. See the incident log in **Status → Self-healing log** (`watchdog.enabled` in `aios.config.yaml`).
171172
- **Settings** — edit provider/key/model, channel tokens, and `aios.config.yaml`; it re-renders into every agent, no terminal needed.
172173
- **Themes** — Light, Dark, Midnight, Slate, Rose. Chat renders markdown (headers, bullets, code blocks).
173174

aios.config.example.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,10 @@ updates:
5454
check_on_start: true # on `aios start`, check the repo for updates and notify
5555
auto_update: true # on `aios start`, auto git-pull + reinstall if the repo changed
5656

57+
watchdog:
58+
enabled: true # if an agent stops responding, auto-restart it
59+
interval: 45 # seconds between health sweeps; a healthy agent diagnoses failures
60+
5761
# Health-check URLs aios waits on during `start` and checks in `status`/`smoke`.
5862
health:
5963
opencode: http://127.0.0.1:4096/

aios.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -467,6 +467,9 @@ def service_specs(cfg: dict) -> dict:
467467
"AIOS_HUB_PORT": str(hport),
468468
"AIOS_OPENCODE_DIR": str(oc / "packages" / "opencode") if oc else "",
469469
"AIOS_BUN": find_tool("bun") or "",
470+
# watchdog: auto-restart downed agents, then have an agent diagnose failures
471+
"AIOS_WATCHDOG": "1" if cfg_get(cfg, "watchdog.enabled", True) else "0",
472+
"AIOS_WATCHDOG_INTERVAL": str(cfg_get(cfg, "watchdog.interval", 45)),
470473
},
471474
}
472475
return specs
@@ -1203,12 +1206,13 @@ def wire_openclaw_os(quiet=False):
12031206
def cmd_start(args):
12041207
cfg = load_config()
12051208
secrets = load_env(ENV_PATH)
1206-
if cfg_get(cfg, "updates.auto_update", False):
1209+
_no_upd = os.environ.get("AIOS_NO_UPDATE_CHECK") == "1" # set by the hub's watchdog
1210+
if not _no_upd and cfg_get(cfg, "updates.auto_update", False):
12071211
if _git_pull() is True: # new commits arrived → refresh deps/config
12081212
_install_all(cfg)
12091213
render_native(cfg, secrets)
12101214
cfg = load_config()
1211-
elif cfg_get(cfg, "updates.check_on_start", True):
1215+
elif not _no_upd and cfg_get(cfg, "updates.check_on_start", True):
12121216
_check_updates_notice()
12131217
if not secrets.get("AIOS_LLM_API_KEY") and not any(secrets.get(v) for v in PROVIDER_VAR.values()):
12141218
warn("no model API key set — the stack runs, but agents need a key to answer "

aios_hub.py

Lines changed: 209 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ def run_aios(*a, background=False):
7373
env = dict(os.environ)
7474
for k in ("AIOS_LLM_PROVIDER", "AIOS_LLM_API_KEY", "AIOS_LLM_BASE_URL", "AIOS_DEFAULT_MODEL"):
7575
env.pop(k, None)
76+
env["AIOS_NO_UPDATE_CHECK"] = "1" # never git-pull during a hub-triggered restart
7677
if background:
7778
threading.Thread(target=lambda: subprocess.run(cmd, cwd=str(ROOT), env=env,
7879
capture_output=True), daemon=True).start()
@@ -392,6 +393,196 @@ def dry_run(target: str, message: str, history: list | None = None) -> dict:
392393
"free": free, "priced": priced, "est_cost_usd": round(cost, 6), "note": note}
393394

394395

396+
# --------------------------------------------------------------------------- #
397+
# Watchdog — if an agent goes down, restart it; if that fails, an agent debugs #
398+
# --------------------------------------------------------------------------- #
399+
HEALTH_FILE = ROOT / ".aios" / "health_events.json"
400+
WATCH = ["opencode", "hermes", "openclaw", "crewai", "claudecode"]
401+
_wd = {"seen_up": {}, "last_restart": {}}
402+
403+
404+
def _log_event(kind: str, svc: str, msg: str):
405+
ev = _load_json(HEALTH_FILE, [])
406+
ev.insert(0, {"ts": time.time(), "kind": kind, "service": svc, "message": str(msg)[:2000]})
407+
_save_json(HEALTH_FILE, ev[:60])
408+
409+
410+
def _service_logs(svc: str, n: int = 40) -> str:
411+
return (run_aios("logs", svc, "-n", str(n)).get("out") or "")[-3000:]
412+
413+
414+
def _diagnose(svc: str, logtail: str) -> str:
415+
"""Ask a healthy agent to debug the crashed one."""
416+
q = (f"The AI OS service '{svc}' stopped responding and an automatic restart did not fix it.\n"
417+
f"Log tail:\n\n{logtail}\n\n"
418+
"In at most 3 bullets: the likely root cause, and the exact command to fix it.")
419+
try:
420+
helper = "opencode" if ping(PEERS.get("opencode", "")) else "brain"
421+
return route(helper, q)
422+
except Exception as e:
423+
return f"(diagnosis unavailable: {e})"
424+
425+
426+
def _watchdog_loop():
427+
if os.environ.get("AIOS_WATCHDOG", "1") != "1":
428+
return
429+
interval = int(os.environ.get("AIOS_WATCHDOG_INTERVAL", "45"))
430+
time.sleep(20) # let the stack finish booting before we judge it
431+
while True:
432+
try:
433+
for svc in WATCH:
434+
url = PEERS.get(svc)
435+
if not url:
436+
continue
437+
if ping(url):
438+
_wd["seen_up"][svc] = time.time()
439+
continue
440+
# Only heal services we've actually seen alive (never auto-start disabled ones)
441+
if not _wd["seen_up"].get(svc):
442+
continue
443+
if time.time() - _wd["last_restart"].get(svc, 0) < 180:
444+
continue # rate-limit: no restart storms
445+
_wd["last_restart"][svc] = time.time()
446+
_log_event("down", svc, f"{svc} stopped responding — auto-restarting…")
447+
run_aios("restart", svc)
448+
time.sleep(20)
449+
if ping(url):
450+
_log_event("healed", svc, f"{svc} is back up (automatic restart).")
451+
else:
452+
diag = _diagnose(svc, _service_logs(svc))
453+
_log_event("failed", svc, f"Restart didn't fix {svc}. Agent diagnosis:\n{diag}")
454+
except Exception:
455+
pass
456+
time.sleep(interval)
457+
458+
459+
# --------------------------------------------------------------------------- #
460+
# Claude login from the dashboard (interactive CLI session over HTTP) #
461+
# --------------------------------------------------------------------------- #
462+
class _LoginSession:
463+
proc = None
464+
master = None
465+
out = ""
466+
lock = threading.Lock()
467+
468+
469+
_login = _LoginSession()
470+
_ANSI = __import__("re").compile(r"\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07]*\x07|\r")
471+
472+
473+
def _claude_bin():
474+
return shutil.which("claude") or shutil.which("claude.cmd")
475+
476+
477+
def claude_login_start(mode: str = "setup-token") -> dict:
478+
claude = _claude_bin()
479+
if not claude:
480+
return {"ok": False, "error": "`claude` CLI not found on PATH."}
481+
if _login.proc and _login.proc.poll() is None:
482+
return {"ok": True, "already": True}
483+
with _login.lock:
484+
_login.out = ""
485+
args = [claude] + ([mode] if mode else [])
486+
try:
487+
if os.name != "nt":
488+
import pty
489+
m, s = pty.openpty()
490+
_login.master = m
491+
_login.proc = subprocess.Popen(args, stdin=s, stdout=s, stderr=s,
492+
env=dict(os.environ), close_fds=True)
493+
os.close(s)
494+
495+
def _rd():
496+
while True:
497+
try:
498+
d = os.read(m, 4096)
499+
except OSError:
500+
break
501+
if not d:
502+
break
503+
with _login.lock:
504+
_login.out += d.decode(errors="replace")
505+
threading.Thread(target=_rd, daemon=True).start()
506+
else:
507+
_login.master = None
508+
_login.proc = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
509+
stderr=subprocess.STDOUT, text=True, bufsize=1,
510+
env=dict(os.environ))
511+
512+
def _rd():
513+
for line in _login.proc.stdout:
514+
with _login.lock:
515+
_login.out += line
516+
threading.Thread(target=_rd, daemon=True).start()
517+
return {"ok": True}
518+
except Exception as e:
519+
return {"ok": False, "error": str(e)}
520+
521+
522+
def claude_login_input(text: str) -> dict:
523+
if not (_login.proc and _login.proc.poll() is None):
524+
return {"ok": False, "error": "no login session running"}
525+
try:
526+
data = (text or "") + "\n"
527+
if _login.master is not None:
528+
os.write(_login.master, data.encode())
529+
else:
530+
_login.proc.stdin.write(data)
531+
_login.proc.stdin.flush()
532+
return {"ok": True}
533+
except Exception as e:
534+
return {"ok": False, "error": str(e)}
535+
536+
537+
def claude_login_state() -> dict:
538+
with _login.lock:
539+
raw = _login.out[-8000:]
540+
running = bool(_login.proc and _login.proc.poll() is None)
541+
urls = __import__("re").findall(r"https?://[^\s\"'<>]+", raw)
542+
return {"output": _ANSI.sub("", raw), "running": running,
543+
"exit": (_login.proc.poll() if _login.proc else None),
544+
"url": urls[-1] if urls else ""}
545+
546+
547+
def claude_login_stop() -> dict:
548+
if _login.proc and _login.proc.poll() is None:
549+
try:
550+
_login.proc.kill()
551+
except Exception:
552+
pass
553+
return {"ok": True}
554+
555+
556+
def claude_status() -> dict:
557+
"""Is claude-code-api up, and is the Claude CLI actually authenticated?"""
558+
st = {"service_up": False, "version": "", "logged_in": False, "model": "", "error": ""}
559+
try:
560+
h = json.loads(urllib.request.urlopen(CLAUDECODE + "/health", timeout=6).read())
561+
st["service_up"] = True
562+
st["version"] = h.get("claude_version", "")
563+
except Exception as e:
564+
st["error"] = f"claude-code-api not reachable — run `aios start claudecode` ({e})"
565+
return st
566+
model = "claude-sonnet-4-5"
567+
try:
568+
ids = _models_from(CLAUDECODE + "/v1")
569+
if ids:
570+
model = next((i for i in ids if "sonnet" in i.lower()), ids[0])
571+
except Exception:
572+
pass
573+
st["model"] = model
574+
try: # cold start of the claude CLI is slow — be generous
575+
b = json.dumps({"model": model, "messages": [{"role": "user", "content": "hi"}],
576+
"max_tokens": 16}).encode()
577+
rq = urllib.request.Request(CLAUDECODE + "/v1/chat/completions", data=b, method="POST",
578+
headers={"Content-Type": "application/json"})
579+
urllib.request.urlopen(rq, timeout=180)
580+
st["logged_in"] = True
581+
except Exception as e:
582+
st["error"] = f"Claude CLI not authenticated (or timed out): {str(e)[:200]}"
583+
return st
584+
585+
395586
# --------------------------------------------------------------------------- #
396587
# "Team" — a single agent that orchestrates the others (the practical merge) #
397588
# --------------------------------------------------------------------------- #
@@ -560,6 +751,12 @@ def do_GET(self):
560751
self._send(200, {"usage": u, "total": sum(u.values()), "free_requests": free})
561752
elif self.path == "/api/ui":
562753
self._send(200, {"widgets": _load_json(WIDGETS_FILE, [])})
754+
elif self.path == "/api/health_events":
755+
self._send(200, {"events": _load_json(HEALTH_FILE, [])})
756+
elif self.path == "/api/claude_status":
757+
self._send(200, claude_status())
758+
elif self.path == "/api/claude_login":
759+
self._send(200, claude_login_state())
563760
elif self.path in ("/v1/models", "/api/v1/models"):
564761
# AIOS as an OpenAI-compatible API: its "models" are the chat targets.
565762
now = int(time.time())
@@ -639,7 +836,7 @@ def do_POST(self):
639836
rq = urllib.request.Request(CLAUDECODE + "/v1/chat/completions", data=b, method="POST",
640837
headers={"Content-Type": "application/json",
641838
"Authorization": "Bearer sk-aios-claudecode"})
642-
urllib.request.urlopen(rq, timeout=45)
839+
urllib.request.urlopen(rq, timeout=180) # claude CLI cold start is slow
643840
logged_in = True
644841
except Exception as e:
645842
detail = str(e)[:160]
@@ -649,6 +846,16 @@ def do_POST(self):
649846
"Pointed at claude-code, but the Claude CLI isn't logged in yet. Run "
650847
"`aios claude-login` (or `claude setup-token`) in a terminal to authorize your "
651848
"Pro/Max account, then chat.")})
849+
elif self.path == "/api/claude_login":
850+
op = payload.get("op", "start")
851+
if op == "start":
852+
self._send(200, claude_login_start(payload.get("mode", "setup-token")))
853+
elif op == "input":
854+
self._send(200, claude_login_input(payload.get("text", "")))
855+
elif op == "stop":
856+
self._send(200, claude_login_stop())
857+
else:
858+
self._send(400, {"error": "unknown op"})
652859
elif self.path == "/api/dryrun":
653860
self._send(200, dry_run(payload.get("target", "brain"), payload.get("message", ""),
654861
payload.get("history", [])))
@@ -752,6 +959,7 @@ def do_POST(self):
752959

753960
def main():
754961
threading.Thread(target=_scheduler_loop, daemon=True).start()
962+
threading.Thread(target=_watchdog_loop, daemon=True).start() # auto-heal downed agents
755963
# Bind all interfaces by default so the Windows browser can reach it over WSL.
756964
host = os.environ.get("AIOS_HUB_HOST", "0.0.0.0")
757965
srv = ThreadingHTTPServer((host, PORT), Handler)

0 commit comments

Comments
 (0)