Phase 1 of the standalone agent framework. Pure refactor, behaviour identical:
- host.mjs: shared runtime (route registry, one runClaudeJson/runClaudeStream
replacing 5 inline spawns, build/git/phpCli, SSE job model, scheduler, core
/ping /auth /logout)
- agents/{in-page-console,project-builder,web-designer,qa}.mjs: each exports
register(host), owns its routes/triggers/persona
- server.mjs: bootstrap that loads host + registers agents
- web-designer exposes host.drainTasks; qa autofix kicks it
Verified: QA pass, Web Designer queue task, heartbeat, all endpoints.
225 lines
8.9 KiB
JavaScript
225 lines
8.9 KiB
JavaScript
/**
|
|
* 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";
|
|
|
|
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 = "Read Write Edit Glob Grep WebSearch";
|
|
const DEFAULT_MODEL = "claude-sonnet-4-6";
|
|
const GIT_SCOPE = ["app", "brand", "api/db", "api/cli"]; // paths the console may commit
|
|
|
|
// ---- 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 || "/var/lib/carlos-arias-agent",
|
|
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
|