seedproject-web/agents/console/host.mjs

226 lines
8.9 KiB
JavaScript
Raw Normal View History

/**
* 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