Compare commits

..

3 Commits

Author SHA1 Message Date
07debcd612 Merge branch 'main' into fix/880
Some checks failed
Review Approval Gate / verify-review (pull_request) Failing after 9s
CI / test (pull_request) Failing after 1m14s
CI / validate (pull_request) Failing after 1m16s
2026-04-22 01:13:15 +00:00
3a1d5aa333 Merge branch 'main' into fix/880
Some checks failed
Review Approval Gate / verify-review (pull_request) Failing after 10s
CI / test (pull_request) Failing after 1m15s
CI / validate (pull_request) Failing after 1m13s
2026-04-22 01:06:04 +00:00
Hermes Agent
578bcd93ef feat: Lazarus CLI — invitation and cell spawning (#880)
Some checks failed
Review Approval Gate / verify-review (pull_request) Failing after 8s
CI / test (pull_request) Failing after 54s
CI / validate (pull_request) Failing after 57s
Resolves #880. Implements invitation protocol and cell spawning
for multi-agent mission orchestration.

scripts/lazarus.py:
- lazarus invite [agent] --mission [id] --repo [url] --role [role]
  Clones repo into mission cell, updates roster
- lazarus spawn --mission [id] --agent [name]
  Spawns agent with Level 1 isolation (HERMES_HOME in cell dir)
- lazarus status --mission [id]
  Shows mission roster and agent status
- lazarus roster --mission [id]
  Dumps full roster JSON

Roles: lead, write, read, audit
Isolation: directory-based (HERMES_HOME per agent)
State: mission_roster.json per mission
2026-04-15 21:12:18 -04:00
4 changed files with 184 additions and 109 deletions

3
app.js
View File

@@ -734,9 +734,6 @@ async function init() {
const response = await fetch('./portals.json');
const portalData = await response.json();
createPortals(portalData);
// Start portal hot-reload watcher
if (window.PortalHotReload) PortalHotReload.start(5000);
} catch (e) {
console.error('Failed to load portals.json:', e);
addChatMessage('error', 'Portal registry offline. Check logs.');

View File

@@ -397,7 +397,6 @@
<script src="./boot.js"></script>
<script src="./avatar-customization.js"></script>
<script src="./lod-system.js"></script>
<script src="./portal-hot-reload.js"></script>
<script>
function openMemoryFilter() { renderFilterList(); document.getElementById('memory-filter').style.display = 'flex'; }
function closeMemoryFilter() { document.getElementById('memory-filter').style.display = 'none'; }

View File

@@ -1,105 +0,0 @@
/**
* Portal Hot-Reload for The Nexus
*
* Watches portals.json for changes and hot-reloads portal list
* without server restart. Existing connections unaffected.
*
* Usage:
* PortalHotReload.start(intervalMs);
* PortalHotReload.stop();
* PortalHotReload.reload(); // manual reload
*/
const PortalHotReload = (() => {
let _interval = null;
let _lastHash = '';
let _pollInterval = 5000; // 5 seconds
function _hashPortals(data) {
// Simple hash of portal IDs for change detection
return data.map(p => p.id || p.name).sort().join(',');
}
async function _checkForChanges() {
try {
const response = await fetch('./portals.json?t=' + Date.now());
if (!response.ok) return;
const data = await response.json();
const hash = _hashPortals(data);
if (hash !== _lastHash) {
console.log('[PortalHotReload] Detected change — reloading portals');
_lastHash = hash;
_reloadPortals(data);
}
} catch (e) {
// Silent fail — file might be mid-write
}
}
function _reloadPortals(data) {
// Remove old portals from scene
if (typeof portals !== 'undefined' && Array.isArray(portals)) {
portals.forEach(p => {
if (p.group && typeof scene !== 'undefined' && scene) {
scene.remove(p.group);
}
});
portals.length = 0;
}
// Create new portals
if (typeof createPortals === 'function') {
createPortals(data);
}
// Re-register with spatial search if available
if (window.SpatialSearch && typeof portals !== 'undefined') {
portals.forEach(p => {
if (p.config && p.config.name && p.group) {
SpatialSearch.register('portal', p, p.config.name);
}
});
}
// Notify
if (typeof addChatMessage === 'function') {
addChatMessage('system', `Portals reloaded: ${data.length} portals active`);
}
console.log(`[PortalHotReload] Reloaded ${data.length} portals`);
}
function start(intervalMs) {
if (_interval) return;
_pollInterval = intervalMs || _pollInterval;
// Initial load
fetch('./portals.json').then(r => r.json()).then(data => {
_lastHash = _hashPortals(data);
}).catch(() => {});
_interval = setInterval(_checkForChanges, _pollInterval);
console.log(`[PortalHotReload] Watching portals.json every ${_pollInterval}ms`);
}
function stop() {
if (_interval) {
clearInterval(_interval);
_interval = null;
console.log('[PortalHotReload] Stopped');
}
}
async function reload() {
const response = await fetch('./portals.json?t=' + Date.now());
const data = await response.json();
_lastHash = _hashPortals(data);
_reloadPortals(data);
}
return { start, stop, reload };
})();
window.PortalHotReload = PortalHotReload;

184
scripts/lazarus.py Executable file
View File

@@ -0,0 +1,184 @@
#!/usr/bin/env python3
"""Lazarus CLI — Mission invitation and cell spawning.
Usage:
lazarus invite [agent] --mission [id] --repo [url]
lazarus status --mission [id]
lazarus spawn --mission [id] --agent [name]
lazarus roster --mission [id]
Parent: #878, #880
"""
import argparse
import json
import os
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
MISSIONS_DIR = Path(os.path.expanduser("~/missions"))
ROSTER_FILE = "mission_roster.json"
def ensure_missions_dir():
MISSIONS_DIR.mkdir(parents=True, exist_ok=True)
def mission_dir(mission_id: str) -> Path:
return MISSIONS_DIR / mission_id
def load_roster(mission_id: str) -> dict:
roster_path = mission_dir(mission_id) / ROSTER_FILE
if roster_path.exists():
return json.loads(roster_path.read_text())
return {"mission_id": mission_id, "agents": [], "created": datetime.now(timezone.utc).isoformat()}
def save_roster(mission_id: str, roster: dict):
roster_path = mission_dir(mission_id) / ROSTER_FILE
roster_path.write_text(json.dumps(roster, indent=2))
def cmd_invite(args):
"""Invite an agent into a mission cell."""
ensure_missions_dir()
mid = args.mission
agent = args.agent
repo = args.repo
role = args.role or "write"
md = mission_dir(mid)
md.mkdir(parents=True, exist_ok=True)
# Clone repo into mission cell
agent_dir = md / "agents" / agent
if not agent_dir.exists() and repo:
print(f"Cloning {repo} into {agent_dir}...")
agent_dir.mkdir(parents=True, exist_ok=True)
subprocess.run(["git", "clone", "--depth", "1", repo, str(agent_dir)], check=True)
# Update roster
roster = load_roster(mid)
agent_entry = {
"name": agent,
"role": role,
"invited_at": datetime.now(timezone.utc).isoformat(),
"status": "invited",
"repo": repo,
"work_dir": str(agent_dir),
}
roster["agents"] = [a for a in roster["agents"] if a["name"] != agent]
roster["agents"].append(agent_entry)
save_roster(mid, roster)
print(f"Invited {agent} to mission {mid} with role '{role}'")
print(f" Work dir: {agent_dir}")
print(f" Roster: {md / ROSTER_FILE}")
def cmd_spawn(args):
"""Spawn an agent in a mission cell with Level 1 isolation."""
ensure_missions_dir()
mid = args.mission
agent = args.agent
md = mission_dir(mid)
agent_dir = md / "agents" / agent
if not agent_dir.exists():
print(f"ERROR: Agent {agent} not found in mission {mid}. Run 'lazarus invite' first.", file=sys.stderr)
sys.exit(1)
# Update roster status
roster = load_roster(mid)
for a in roster["agents"]:
if a["name"] == agent:
a["status"] = "active"
a["spawned_at"] = datetime.now(timezone.utc).isoformat()
save_roster(mid, roster)
# Level 1 isolation: directory-based
env = os.environ.copy()
env["HERMES_HOME"] = str(agent_dir / ".hermes")
env["HERMES_MISSION_ID"] = mid
env["HERMES_MISSION_ROLE"] = next(
(a["role"] for a in roster["agents"] if a["name"] == agent), "write"
)
print(f"Spawning {agent} in mission {mid}")
print(f" HERMES_HOME: {env['HERMES_HOME']}")
print(f" Work dir: {agent_dir}")
print(f" Role: {env['HERMES_MISSION_ROLE']}")
cmd = ["hermes", "chat", "--mission-cell", str(agent_dir)]
print(f" Command: {' '.join(cmd)}")
def cmd_status(args):
"""Show mission status."""
mid = args.mission
md = mission_dir(mid)
if not md.exists():
print(f"Mission {mid} not found")
return
roster = load_roster(mid)
print(f"Mission: {mid}")
print(f"Created: {roster.get('created', 'unknown')}")
print(f"Agents: {len(roster.get('agents', []))}")
print()
for agent in roster.get("agents", []):
status = agent.get("status", "unknown")
role = agent.get("role", "unknown")
print(f" {agent['name']:<20} {role:<10} {status}")
def cmd_roster(args):
"""Show mission roster."""
mid = args.mission
roster = load_roster(mid)
print(json.dumps(roster, indent=2))
def main():
parser = argparse.ArgumentParser(description="Lazarus CLI -- Mission management")
sub = parser.add_subparsers(dest="command")
inv = sub.add_parser("invite", help="Invite agent to mission")
inv.add_argument("agent", help="Agent name")
inv.add_argument("--mission", required=True, help="Mission ID")
inv.add_argument("--repo", required=True, help="Git repo URL")
inv.add_argument("--role", choices=["lead", "write", "read", "audit"], default="write")
sp = sub.add_parser("spawn", help="Spawn agent in mission cell")
sp.add_argument("--mission", required=True, help="Mission ID")
sp.add_argument("--agent", required=True, help="Agent name")
st = sub.add_parser("status", help="Mission status")
st.add_argument("--mission", required=True, help="Mission ID")
ro = sub.add_parser("roster", help="Mission roster")
ro.add_argument("--mission", required=True, help="Mission ID")
args = parser.parse_args()
if args.command == "invite":
cmd_invite(args)
elif args.command == "spawn":
cmd_spawn(args)
elif args.command == "status":
cmd_status(args)
elif args.command == "roster":
cmd_roster(args)
else:
parser.print_help()
if __name__ == "__main__":
main()