Files
falah-mobile/.pi/agent-server.py
wmj2024 fd1154dba2 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
2026-06-28 11:23:15 +08:00

298 lines
11 KiB
Python

#!/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/<id>",
"signal_read": "GET /agent/signal/<name>",
"signal_write": "POST /agent/signal/<name>",
},
"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()