/** * AstroAgent host — the generic runtime that hosts pluggable agents. * * Provides the shared primitives every agent reuses (Claude spawn, build, git, * the PHP-CLI DB bridge, SSE job model, a route registry, and a scheduler) and * knows NOTHING agent-specific. Agent modules under ./agents/ call * `host.route(...)` / `host.everyMinutes(...)` and use `host.runClaudeJson` etc. * * Reached only through nginx (auth_request against the PHP admin session); this * process does not re-check auth. Node built-ins only — no dependencies. */ import { createServer } from "node:http"; import { spawn } from "node:child_process"; import { randomUUID } from "node:crypto"; import { readFileSync, writeFileSync, existsSync, rmSync, mkdirSync } from "node:fs"; import { dirname, resolve, join } from "node:path"; import { fileURLToPath } from "node:url"; import { site } from "./site.mjs"; const HERE = dirname(fileURLToPath(import.meta.url)); // agents/console const REPO = resolve(HERE, "..", ".."); // repo root const APP = join(REPO, "app"); const PREVIEW_DIR = join(REPO, "public-preview"); const ENV_FILE = join(REPO, "agents", ".env"); const CLAUDE = "/usr/local/bin/claude"; const PHP = "/usr/bin/php"; const PORT = Number(process.env.ADMIN_PORT || 3011); const AGENT_TOOLS = site.tools; // from astroagent.config.json ai.tools const DEFAULT_MODEL = site.model; // from astroagent.config.json ai.model const GIT_SCOPE = ["app", "brand", "api/db", "api/cli"]; // SeedProject-conventional commit scope // ---- env (.env) -------------------------------------------------------------- function loadEnv() { const out = {}; if (existsSync(ENV_FILE)) { for (const line of readFileSync(ENV_FILE, "utf8").split("\n")) { const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/); if (m) out[m[1]] = m[2]; } } return out; } function saveToken(token) { let s = existsSync(ENV_FILE) ? readFileSync(ENV_FILE, "utf8") : ""; if (/^CLAUDE_CODE_OAUTH_TOKEN=.*$/m.test(s)) { s = s.replace(/^CLAUDE_CODE_OAUTH_TOKEN=.*$/m, `CLAUDE_CODE_OAUTH_TOKEN=${token}`); } else { s += `\nCLAUDE_CODE_OAUTH_TOKEN=${token}\n`; } writeFileSync(ENV_FILE, s, { mode: 0o600 }); } function agentEnv() { const env = loadEnv(); return { ...process.env, HOME: process.env.HOME || site.home, PATH: "/usr/local/bin:/usr/bin:/bin", CLAUDE_CODE_OAUTH_TOKEN: env.CLAUDE_CODE_OAUTH_TOKEN || "", }; } // ---- Claude spawn (the ONE place agents invoke claude) ----------------------- /** One-shot: `claude -p … --output-format json`. Resolves {ok, result}. */ function runClaudeJson({ prompt, tools = "", model }) { return new Promise((res) => { const child = spawn(CLAUDE, ["-p", prompt, "--output-format", "json", "--model", model || DEFAULT_MODEL, "--allowedTools", tools], { cwd: REPO, env: agentEnv() }); let out = ""; child.stdout.on("data", (d) => (out += d)); child.on("close", () => { try { const env = JSON.parse(out); res({ ok: true, result: typeof env.result === "string" ? env.result : "" }); } catch { res({ ok: false, result: "" }); } }); child.on("error", () => res({ ok: false, result: "" })); }); } /** Streaming: `claude -p … --output-format stream-json`. Calls onMessage per NDJSON line; returns the child so the caller wires close/error. */ function runClaudeStream({ prompt, tools = AGENT_TOOLS, model, resume, onMessage, onStderr, onClose, onError }) { const args = ["-p", prompt, "--output-format", "stream-json", "--verbose", "--allowedTools", tools, "--model", model || DEFAULT_MODEL]; if (resume) args.push("--resume", resume); const child = spawn(CLAUDE, args, { cwd: REPO, env: agentEnv() }); let buf = ""; child.stdout.on("data", (chunk) => { buf += chunk.toString(); let nl; while ((nl = buf.indexOf("\n")) >= 0) { const line = buf.slice(0, nl).trim(); buf = buf.slice(nl + 1); if (!line) continue; let msg; try { msg = JSON.parse(line); } catch { continue; } onMessage && onMessage(msg); } }); child.stderr.on("data", (d) => onStderr && onStderr(d.toString())); child.on("close", (code) => onClose && onClose(code)); child.on("error", (err) => onError && onError(err)); return child; } // ---- build / git / php-cli --------------------------------------------------- function build(extraEnv) { return new Promise((res) => { const child = spawn("npm", ["run", "build"], { cwd: APP, env: { ...agentEnv(), ...extraEnv } }); let tail = ""; const grab = (d) => { tail = (tail + d.toString()).slice(-4000); }; child.stdout.on("data", grab); child.stderr.on("data", grab); child.on("close", (code) => res({ ok: code === 0, tail })); child.on("error", () => res({ ok: false, tail: "build failed to start" })); }); } function git(args) { return new Promise((res) => { const child = spawn("git", args, { cwd: REPO, env: agentEnv() }); let tail = ""; child.stdout.on("data", (d) => (tail += d)); child.stderr.on("data", (d) => (tail += d)); child.on("close", (code) => res({ ok: code === 0, tail: tail.toString() })); child.on("error", () => res({ ok: false, tail: "" })); }); } function phpCli(args) { return new Promise((res) => { const child = spawn(PHP, args, { cwd: REPO, env: agentEnv() }); let out = "", err = ""; child.stdout.on("data", (d) => (out += d)); child.stderr.on("data", (d) => (err += d)); child.on("close", () => res({ out, err })); child.on("error", () => res({ out: "", err: "php spawn failed" })); }); } // ---- HTTP helpers ------------------------------------------------------------ function json(res, status, obj) { res.writeHead(status, { "content-type": "application/json; charset=utf-8" }); res.end(JSON.stringify(obj)); } function readBody(req) { return new Promise((res) => { let b = ""; req.on("data", (c) => (b += c)); req.on("end", () => { try { res(b ? JSON.parse(b) : {}); } catch { res({}); } }); }); } // ---- SSE job model ----------------------------------------------------------- const jobs = new Map(); function newJob(page) { const conversationId = randomUUID(); const job = { conversationId, jobId: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`, sessionId: null, page: page || "/", events: [], clients: new Set(), done: false, }; jobs.set(conversationId, job); return job; } function emit(job, ev) { job.events.push(ev); const line = `data: ${JSON.stringify(ev)}\n\n`; for (const res of job.clients) { try { res.write(line); } catch {} } } // ---- the host singleton ------------------------------------------------------ const _routes = []; const _schedules = []; export const host = { // paths / constants (Phase 2: source from config) REPO, APP, PREVIEW_DIR, CLAUDE, PHP, PORT, AGENT_TOOLS, DEFAULT_MODEL, gitScope: GIT_SCOPE, // node fs re-exports agents need rmSync, join, // shared single-flight lock (mutable by reference across agent modules) state: { busy: false }, // env + claude + ops + http + SSE loadEnv, saveToken, agentEnv, runClaudeJson, runClaudeStream, build, git, phpCli, json, readBody, jobs, newJob, emit, /** Register an HTTP route. method "ANY" matches any verb. */ route(method, path, handler) { _routes.push({ method, path, handler }); }, /** Run fn every `min` minutes, plus one pulse `bootDelayMs` after listen(). */ everyMinutes(min, fn, { bootDelayMs = 90_000 } = {}) { if (min > 0) _schedules.push({ min, fn, bootDelayMs }); }, /** Start the server + scheduled tasks. */ listen() { const server = createServer(async (req, res) => { const url = new URL(req.url, "http://localhost"); const path = url.pathname.replace(/^\/devconsole/, "") || "/"; const method = req.method || "GET"; try { const route = _routes.find((r) => r.path === path && (r.method === "ANY" || r.method === method)); if (!route) return json(res, 404, { error: "not found" }); await route.handler(req, res, { url, path, method }); } catch (err) { json(res, 500, { error: err.message }); } }); if (!existsSync(PREVIEW_DIR)) mkdirSync(PREVIEW_DIR, { recursive: true }); server.listen(PORT, "127.0.0.1", () => console.log(`[console] runner on 127.0.0.1:${PORT}, repo ${REPO}`)); for (const s of _schedules) { setTimeout(s.fn, s.bootDelayMs); setInterval(s.fn, s.min * 60_000); } }, }; // ---- core routes (token/health — framework-level, agent-independent) --------- host.route("ANY", "/ping", (req, res) => { const env = loadEnv(); json(res, 200, { authed: true, hasToken: Boolean(env.CLAUDE_CODE_OAUTH_TOKEN) }); }); host.route("POST", "/auth", async (req, res) => { const { token } = await readBody(req); if (!token || !/^sk-ant-/.test(token)) return json(res, 400, { error: "That doesn't look like a Claude token (expected sk-ant-…)." }); saveToken(token.trim()); json(res, 200, { ok: true }); }); host.route("POST", "/logout", (req, res) => json(res, 200, { ok: true })); // admin session cleared by PHP