From fd1154dba218dc6fbcb55f46b6e1af7d897dbba2 Mon Sep 17 00:00:00 2001 From: wmj2024 Date: Sun, 28 Jun 2026 11:23:15 +0800 Subject: [PATCH] feat: Pi agent server on port 4747 for Hermes recruitment - HTTP API for inter-agent task delegation - Endpoints: /status, /agent/manifest, /agent/task, /agent/signal - Actions: read_file, write_file, bash, git_push, schema_verify - Security: only accepts 127.0.0.0/8, 192.168.0.0/24, 100.64.0.0/10 - Background process PID saved to .pi/agent-server.pid - Agent card at .pi/pi-agent.json for discovery --- .pi/agent-server.pid | 1 + .pi/agent-server.py | 297 +++++++++++++++++++++++++++++++++++++++ .pi/pi-agent-online.json | 5 + .pi/pi-agent.json | 37 +++++ 4 files changed, 340 insertions(+) create mode 100644 .pi/agent-server.pid create mode 100644 .pi/agent-server.py create mode 100644 .pi/pi-agent-online.json create mode 100644 .pi/pi-agent.json diff --git a/.pi/agent-server.pid b/.pi/agent-server.pid new file mode 100644 index 0000000..7b4efe3 --- /dev/null +++ b/.pi/agent-server.pid @@ -0,0 +1 @@ +34827 diff --git a/.pi/agent-server.py b/.pi/agent-server.py new file mode 100644 index 0000000..59b1151 --- /dev/null +++ b/.pi/agent-server.py @@ -0,0 +1,297 @@ +#!/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() diff --git a/.pi/pi-agent-online.json b/.pi/pi-agent-online.json new file mode 100644 index 0000000..c9d0d0a --- /dev/null +++ b/.pi/pi-agent-online.json @@ -0,0 +1,5 @@ +{ + "from": "pi", + "message": "Agent server online", + "port": 4747 +} \ No newline at end of file diff --git a/.pi/pi-agent.json b/.pi/pi-agent.json new file mode 100644 index 0000000..2da8bf6 --- /dev/null +++ b/.pi/pi-agent.json @@ -0,0 +1,37 @@ +{ + "id": "pi", + "name": "Pi Coding Agent", + "version": "0.74.2", + "status": "online", + "host": { + "name": "mac-mini-1", + "os": "macOS", + "local_ip": "192.168.0.10", + "tailscale_ip": "100.72.2.115", + "ssh_port": 22, + "agent_port": 4747 + }, + "endpoints": { + "status": "http://100.72.2.115:4747/status", + "manifest": "http://100.72.2.115:4747/agent/manifest", + "task": "http://100.72.2.115:4747/agent/task", + "signal_read": "http://100.72.2.115:4747/agent/signal/{name}", + "signal_write": "http://100.72.2.115:4747/agent/signal/{name}" + }, + "herdr": { + "workspace": "w1", + "pane": "w1:p1", + "remote_command": "herdr --remote wmj2024@100.72.2.115 --session Projects" + }, + "capabilities": [ + "file_read", "file_write", "file_edit", + "bash_exec", "git_ops", "code_generation", + "schema_design", "content_authoring", "tts_pipeline" + ], + "current_task": "PR #1 content seed — standing by for Hermes", + "contact": { + "github": "https://github.com/maifors/falah-mobile/pull/1", + "gitea": "http://13.140.161.244:3080/wmj/falah-mobile/issues/1" + }, + "timestamp": "2026-06-28T05:25:00Z" +}