#!/usr/bin/env python3 """ Pi Agent Server — Lightweight HTTP API for inter-agent communication. Hermes (or other agents) can discover, task, and query Pi via this endpoint. Bind: 0.0.0.0:4747 Security: Accepts only localhost, local LAN (192.168.0.0/24), and Tailscale (100.64.0.0/10) """ import http.server import json import os import socketserver import subprocess import sys import threading import time import uuid from datetime import datetime, timezone from urllib.parse import urlparse PORT = 4747 ALLOWED_NETWORKS = [ ("127.0.0.0", 8), # localhost ("192.168.0.0", 24), # local LAN ("100.64.0.0", 10), # Tailscale CGNAT ] TASKS = {} TASK_LOCK = threading.Lock() def ip_in_allowed_network(client_ip: str) -> bool: """Check if client IP is in an allowed network.""" try: parts = [int(x) for x in client_ip.split(".")] ip_int = (parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3] for network, prefix in ALLOWED_NETWORKS: net_parts = [int(x) for x in network.split(".")] net_int = (net_parts[0] << 24) | (net_parts[1] << 16) | (net_parts[2] << 8) | net_parts[3] mask = (0xFFFFFFFF << (32 - prefix)) & 0xFFFFFFFF if (ip_int & mask) == (net_int & mask): return True return False except Exception: return False class AgentHandler(http.server.BaseHTTPRequestHandler): def log_message(self, format, *args): ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") client = self.client_address[0] print(f"[{ts}] {client} {args[0]}", flush=True) def do_OPTIONS(self): self.send_response(200) self.send_header("Access-Control-Allow-Origin", "*") self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") self.send_header("Access-Control-Allow-Headers", "Content-Type") self.end_headers() def _reject(self, code=403, message="Forbidden"): self.send_response(code) self.send_header("Content-Type", "application/json") self.end_headers() self.wfile.write(json.dumps({"error": message}).encode()) def _json_ok(self, data): self.send_response(200) self.send_header("Content-Type", "application/json") self.end_headers() self.wfile.write(json.dumps(data, indent=2).encode()) def _check_auth(self): client_ip = self.client_address[0] if not ip_in_allowed_network(client_ip): self._reject(403, f"IP {client_ip} not in allowed networks") return False return True def do_GET(self): if not self._check_auth(): return parsed = urlparse(self.path) path = parsed.path # ── Health / Status ── if path == "/status" or path == "/": self._json_ok({ "agent": "pi", "version": "0.74.2", "status": "ready", "timestamp": datetime.now(timezone.utc).isoformat(), "workspace": "/Users/wmj2024/Desktop/Projects/FalahMobile", "tailscale_ip": "100.72.2.115", "local_ip": "192.168.0.10", "tasks_active": len([t for t in TASKS.values() if t["status"] == "running"]), "tasks_completed": len([t for t in TASKS.values() if t["status"] == "done"]), }) return # ── Agent Manifest ── if path == "/agent/manifest": self._json_ok({ "id": "pi", "name": "Pi Coding Agent", "version": "0.74.2", "hostname": "mac-mini-1", "endpoints": { "status": "GET /status", "manifest": "GET /agent/manifest", "task_submit": "POST /agent/task", "task_query": "GET /agent/task/", "signal_read": "GET /agent/signal/", "signal_write": "POST /agent/signal/", }, "capabilities": [ "file_read", "file_write", "file_edit", "bash_exec", "git_ops", "code_generation", "schema_design", "content_authoring", "tts_pipeline", ], "languages": ["typescript", "javascript", "python", "bash", "prisma", "sql"], "platforms": ["macos", "linux", "docker"], "collaboration": { "herdr_workspace": "w1", "github_repo": "maifors/falah-mobile", "gitea_repo": "wmj/falahmobile-content", }, "contact": { "tailscale_ip": "100.72.2.115", "local_ip": "192.168.0.10", "ssh_port": 22, "agent_port": PORT, }, }) return # ── Task Query ── if path.startswith("/agent/task/"): task_id = path.split("/")[-1] task = TASKS.get(task_id) if not task: self._reject(404, "Task not found") return self._json_ok(task) return # ── Signal Read ── if path.startswith("/agent/signal/"): name = path.split("/")[-1] signal_path = f"/Users/wmj2024/Desktop/Projects/FalahMobile/.pi/{name}.json" if os.path.exists(signal_path): with open(signal_path, "r") as f: self._json_ok(json.load(f)) else: self._reject(404, f"Signal {name} not found") return self._reject(404, "Unknown endpoint") def do_POST(self): if not self._check_auth(): return parsed = urlparse(self.path) path = parsed.path content_length = int(self.headers.get("Content-Length", 0)) body = self.rfile.read(content_length).decode("utf-8") if content_length > 0 else "{}" try: payload = json.loads(body) if body else {} except json.JSONDecodeError: self._reject(400, "Invalid JSON body") return # ── Task Submit ── if path == "/agent/task": task_id = str(uuid.uuid4())[:8] task = { "id": task_id, "status": "queued", "created_at": datetime.now(timezone.utc).isoformat(), "completed_at": None, "request": payload, "result": None, "error": None, } with TASK_LOCK: TASKS[task_id] = task # Spawn worker thread threading.Thread(target=self._run_task, args=(task_id,), daemon=True).start() self._json_ok({"task_id": task_id, "status": "queued", "query_url": f"/agent/task/{task_id}"}) return # ── Signal Write ── if path.startswith("/agent/signal/"): name = path.split("/")[-1] signal_path = f"/Users/wmj2024/Desktop/Projects/FalahMobile/.pi/{name}.json" with open(signal_path, "w") as f: json.dump(payload, f, indent=2) self._json_ok({"signal": name, "saved": True, "path": signal_path}) return self._reject(404, "Unknown endpoint") def _run_task(self, task_id): """Execute a task asynchronously.""" with TASK_LOCK: task = TASKS[task_id] task["status"] = "running" req = task["request"] action = req.get("action") try: if action == "read_file": filepath = req.get("path") if not os.path.exists(filepath): raise FileNotFoundError(f"File not found: {filepath}") with open(filepath, "r") as f: result = {"content": f.read()} elif action == "write_file": filepath = req.get("path") content = req.get("content", "") os.makedirs(os.path.dirname(filepath), exist_ok=True) with open(filepath, "w") as f: f.write(content) result = {"path": filepath, "bytes_written": len(content)} elif action == "bash": cmd = req.get("command", "") timeout = req.get("timeout", 60) cwd = req.get("cwd", "/Users/wmj2024/Desktop/Projects/FalahMobile") proc = subprocess.run( cmd, shell=True, capture_output=True, text=True, timeout=timeout, cwd=cwd ) result = { "stdout": proc.stdout, "stderr": proc.stderr, "returncode": proc.returncode, } elif action == "git_push": branch = req.get("branch", "feat/learn-module") remote = req.get("remote", "github") message = req.get("message", "agent: automated commit") cmds = [ ["git", "add", "-A"], ["git", "commit", "-m", message], ["git", "push", remote, branch], ] outputs = [] for c in cmds: proc = subprocess.run(c, capture_output=True, text=True, cwd="/Users/wmj2024/Desktop/Projects/FalahMobile") outputs.append({"cmd": c, "rc": proc.returncode, "out": proc.stdout[-500:], "err": proc.stderr[-500:]}) result = {"operations": outputs} elif action == "schema_verify": schema_path = req.get("path", "prisma/schema.prisma") with open(schema_path, "r") as f: schema = f.read() models = [line.strip() for line in schema.split("\n") if line.strip().startswith("model ")] result = {"models_found": models, "schema_path": schema_path} else: result = {"error": f"Unknown action: {action}"} with TASK_LOCK: task["status"] = "done" task["completed_at"] = datetime.now(timezone.utc).isoformat() task["result"] = result except Exception as e: with TASK_LOCK: task["status"] = "error" task["completed_at"] = datetime.now(timezone.utc).isoformat() task["error"] = str(e) def run_server(): with socketserver.TCPServer(("0.0.0.0", PORT), AgentHandler) as httpd: print(f"[AGENT] Pi server listening on 0.0.0.0:{PORT}", flush=True) print(f"[AGENT] Hermes can connect via:", flush=True) print(f" → Tailscale: http://100.72.2.115:{PORT}/agent/manifest", flush=True) print(f" → Local LAN: http://192.168.0.10:{PORT}/agent/manifest", flush=True) print(f"[AGENT] Allowed networks: {ALLOWED_NETWORKS}", flush=True) httpd.serve_forever() if __name__ == "__main__": run_server()