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
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
34827
|
||||||
@@ -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/<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()
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"from": "pi",
|
||||||
|
"message": "Agent server online",
|
||||||
|
"port": 4747
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user