diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-06 21:59:52 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-06 21:59:52 -0500 |
| commit | d81b23ad6b6e437dfe3c338a00a4be39bc555146 (patch) | |
| tree | 2d4b0d7890fd1fc70d81282b81fed2808c28a106 /.ai/scripts/cross-agent-comms/cross-agent-discover | |
| parent | 201377f57430ef28d02e703a2191434bbee55c75 (diff) | |
| download | rulesets-d81b23ad6b6e437dfe3c338a00a4be39bc555146.tar.gz rulesets-d81b23ad6b6e437dfe3c338a00a4be39bc555146.zip | |
chore(ai): initialize project notes and Claude tooling surfaces
Replace the seed notes.org with project-specific context (layout, install modes, task tracker location, recent inflection point). Bring in the synced template surfaces (protocols, workflows, scripts, references, retrospectives, someday-maybe) as tracked content for this content/documentation project.
Diffstat (limited to '.ai/scripts/cross-agent-comms/cross-agent-discover')
| -rwxr-xr-x | .ai/scripts/cross-agent-comms/cross-agent-discover | 230 |
1 files changed, 230 insertions, 0 deletions
diff --git a/.ai/scripts/cross-agent-comms/cross-agent-discover b/.ai/scripts/cross-agent-comms/cross-agent-discover new file mode 100755 index 0000000..152cf27 --- /dev/null +++ b/.ai/scripts/cross-agent-comms/cross-agent-discover @@ -0,0 +1,230 @@ +#!/usr/bin/env python3 +"""Enumerate cross-agent destinations: local projects + tailnet peers. + +See cross-agent-discover.md. Local: scan ~/projects/*/.ai/. Peers: read +peers.toml, SSH-probe each for reachability. --enumerate-remote optionally +runs `ls -d ~/projects/*/.ai/` over SSH to list remote projects. + +Cache results for 5 min at ~/.cache/cross-agent-comms/discovery.json so +repeated invocations don't re-probe. + +HALT: prints a banner; otherwise continues. +""" + +from __future__ import annotations + +import argparse +import datetime as _dt +import json +import os +import subprocess +import sys +import time +import tomllib +from pathlib import Path + +CONFIG_DIR = Path.home() / ".config" / "cross-agent-comms" +PEERS_TOML = CONFIG_DIR / "peers.toml" +HALT_FILE = CONFIG_DIR / "HALT" +CACHE_DIR = Path.home() / ".cache" / "cross-agent-comms" +CACHE_FILE = CACHE_DIR / "discovery.json" +CACHE_TTL_SECONDS = 300 + +EXIT_OK = 0 +EXIT_GENERAL = 1 +EXIT_PEERS_TOML = 1 + + +def err(msg: str) -> None: + print(msg, file=sys.stderr) + + +def render_banner_if_halt() -> None: + if not HALT_FILE.exists(): + return + try: + reason = HALT_FILE.read_text().strip() + except OSError: + reason = "(HALT file unreadable; treated as halted)" + print("⚠ HALT ACTIVE — cross-agent comms paused") + if reason: + print(f" reason: {reason}") + print() + + +def enumerate_local_projects() -> list[str]: + projects_dir = Path.home() / "projects" + if not projects_dir.is_dir(): + return [] + found = [] + for child in sorted(projects_dir.iterdir()): + if child.is_dir() and (child / ".ai").is_dir(): + found.append(child.name) + return found + + +def load_peers() -> dict: + if not PEERS_TOML.exists(): + return {"peers": {}} + try: + return tomllib.loads(PEERS_TOML.read_text()) + except (tomllib.TOMLDecodeError, OSError) as e: + err(f"cannot parse peers.toml: {e}") + sys.exit(EXIT_PEERS_TOML) + + +def probe_peer_reachability(host: str, ssh_user: str | None) -> tuple[bool, str | None]: + """Run a short SSH probe with BatchMode=yes (no interactive prompt).""" + target = f"{ssh_user}@{host}" if ssh_user else host + try: + result = subprocess.run( + ["ssh", "-o", "ConnectTimeout=2", "-o", "BatchMode=yes", target, "true"], + capture_output=True, + text=True, + timeout=5, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return False, "ssh probe failed" + if result.returncode == 0: + return True, None + return False, (result.stderr.strip().splitlines() or [f"exit {result.returncode}"])[-1] + + +def enumerate_remote_projects(host: str, ssh_user: str | None) -> list[str] | None: + target = f"{ssh_user}@{host}" if ssh_user else host + try: + result = subprocess.run( + [ + "ssh", "-o", "ConnectTimeout=3", "-o", "BatchMode=yes", target, + "ls -d ~/projects/*/.ai/ 2>/dev/null", + ], + capture_output=True, + text=True, + timeout=10, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return None + if result.returncode != 0: + return None + projects = [] + for line in result.stdout.splitlines(): + # Each line looks like /home/<user>/projects/<name>/.ai/ + parts = line.rstrip("/").split("/") + if len(parts) >= 2 and parts[-1] == ".ai": + projects.append(parts[-2]) + return projects + + +def read_cache() -> dict | None: + if not CACHE_FILE.exists(): + return None + try: + age = time.time() - CACHE_FILE.stat().st_mtime + if age > CACHE_TTL_SECONDS: + return None + return json.loads(CACHE_FILE.read_text()) + except (OSError, json.JSONDecodeError): + return None + + +def write_cache(payload: dict) -> None: + CACHE_DIR.mkdir(parents=True, exist_ok=True) + CACHE_FILE.write_text(json.dumps(payload, indent=2)) + + +def discover(peer_filter: str | None, enumerate_remote: bool) -> dict: + local = enumerate_local_projects() + peers_cfg = load_peers().get("peers", {}) + + peers_out = [] + for name, cfg in sorted(peers_cfg.items()): + if peer_filter and name != peer_filter: + continue + host = cfg.get("host", name) + ssh_user = cfg.get("ssh_user") + reachable, error = probe_peer_reachability(host, ssh_user) + entry = { + "name": name, + "host": host, + "reachable": reachable, + } + if not reachable: + entry["error"] = error + if enumerate_remote and reachable: + entry["projects"] = enumerate_remote_projects(host, ssh_user) or [] + peers_out.append(entry) + + return { + "scanned_at": _dt.datetime.now(_dt.timezone.utc).isoformat(), + "halt_active": HALT_FILE.exists(), + "local": local, + "peers": peers_out, + } + + +def render_table(payload: dict, enumerate_remote: bool) -> None: + local = payload.get("local", []) + print(f"Local ({_local_hostname()}):") + if local: + wrapped = ", ".join(local) + print(f" {wrapped} [{len(local)} project{'s' if len(local) != 1 else ''}]") + else: + print(" (no projects with .ai/ found)") + print() + + peers = payload.get("peers", []) + if not peers: + print("Peers (from peers.toml):") + print(" (no peers configured)") + return + + print("Peers (from ~/.config/cross-agent-comms/peers.toml):") + for p in peers: + marker = "✓ reachable" if p.get("reachable") else f"✗ UNREACHABLE ({p.get('error', 'unknown')})" + print(f" {p['name']:<16} {p['host']:<24} {marker}") + if enumerate_remote and p.get("projects"): + wrapped = ", ".join(p["projects"]) + print(f" projects: {wrapped}") + + +def _local_hostname() -> str: + import socket + return socket.gethostname().split(".")[0] + + +def main() -> int: + parser = argparse.ArgumentParser(description="Discover cross-agent destinations.") + parser.add_argument("--enumerate-remote", action="store_true", + help="SSH into each peer and list ~/projects/*/.ai/") + parser.add_argument("--no-cache", action="store_true", help="Skip cache; force fresh probe") + parser.add_argument("--peer", help="Limit to a single peer name from peers.toml") + parser.add_argument("--json", action="store_true", help="Machine-readable output") + args = parser.parse_args() + + render_banner_if_halt() + + payload = None + if not args.no_cache: + cached = read_cache() + if cached is not None: + # Honor --peer filter on cached payload. + if args.peer: + cached["peers"] = [p for p in cached.get("peers", []) if p["name"] == args.peer] + payload = cached + + if payload is None: + payload = discover(args.peer, args.enumerate_remote) + if not args.no_cache and not args.peer: + # Only cache full (unfiltered) discoveries. + write_cache(payload) + + if args.json: + print(json.dumps(payload, indent=2)) + return EXIT_OK + + render_table(payload, args.enumerate_remote) + return EXIT_OK + + +if __name__ == "__main__": + sys.exit(main()) |
