diff --git a/agents/console/agents/in-page-console.mjs b/agents/console/agents/in-page-console.mjs
new file mode 100644
index 0000000..3e916c9
--- /dev/null
+++ b/agents/console/agents/in-page-console.mjs
@@ -0,0 +1,176 @@
+/**
+ * In-page console agent — the freeform ▲ editor. The operator describes a change
+ * on any page; the agent (Read/Write/Edit/Glob/Grep/WebSearch, no Bash) edits the
+ * working tree; the host builds an isolated preview; publish/discard promote or
+ * revert. Owns: /run, /stream (SSE), /publish, /rebuild, /discard.
+ */
+
+let H; // the host, set in register()
+
+function buildPrompt({ message, page, selections }) {
+ const parts = [];
+ parts.push(
+ "You are the editing agent for the Carlos Arias website (an Astro + Tailwind v4 static site).",
+ "Make ONLY the change the operator asks for. Keep everything on-brand:",
+ "the brand guide is brand/BRAND.md and the design tokens are in app/src/styles.css",
+ "(sumi-e — ink on washi paper, one vermillion seal, restraint, near-square radii).",
+ "Do NOT run builds or git commands — the console builds and publishes for you.",
+ "",
+ );
+ if (page && page !== "/") {
+ parts.push(`The operator is on the page: ${page}`);
+ parts.push("Its source is almost certainly under app/src/pages (find it with Glob/Grep).", "");
+ } else if (page === "/") {
+ parts.push("The operator is on the homepage (app/src/pages/index.astro).", "");
+ }
+ if (Array.isArray(selections) && selections.length) {
+ parts.push("They selected these element(s) on the page:");
+ for (const s of selections) {
+ const tag = s.tag || s.selector || "element";
+ parts.push(`- <${tag}>${s.text ? ` — "${String(s.text).slice(0, 80)}"` : ""}${s.comment ? ` — note: ${s.comment}` : ""}`);
+ }
+ parts.push("");
+ }
+ parts.push("Request:", message);
+ return parts.join("\n");
+}
+
+function shortPath(p) {
+ if (!p) return "";
+ return String(p).replace(H.REPO + "/", "");
+}
+function handleStreamMsg(job, msg) {
+ if (msg.session_id) job.sessionId = msg.session_id;
+ const content = msg?.message?.content;
+ if (Array.isArray(content)) {
+ for (const block of content) {
+ if (block.type === "text" && block.text) H.emit(job, { kind: "text", text: block.text });
+ else if (block.type === "tool_use") {
+ const label = block.name === "Edit" || block.name === "Write"
+ ? `${block.name} ${shortPath(block.input?.file_path)}`
+ : block.name;
+ H.emit(job, { kind: "tool", text: label });
+ }
+ }
+ }
+ if (msg.type === "result" && typeof msg.result === "string" && msg.result.trim()) {
+ H.emit(job, { kind: "text", text: msg.result.trim() });
+ }
+}
+
+async function buildPreview(job) {
+ H.emit(job, { kind: "tool", text: "building preview…" });
+ const out = H.join("..", "public-preview", job.jobId);
+ const base = `/_preview/${job.jobId}`;
+ const { ok } = await H.build({ PREVIEW_OUT: out, PREVIEW_BASE: base });
+ if (!ok) {
+ H.emit(job, { type: "error", text: "The change broke the build, so it was not applied. Try rephrasing." });
+ await H.git(["checkout", "--", ...H.gitScope]);
+ H.emit(job, { type: "done" });
+ job.done = true;
+ return;
+ }
+ const target = job.page && job.page !== "/" ? job.page.replace(/^\//, "") : "";
+ H.emit(job, { type: "preview", url: `${base}/${target}` });
+ H.emit(job, { type: "done" });
+ job.done = true;
+}
+
+function runAgent(job, { message, page, selections, model }) {
+ const prompt = buildPrompt({ message, page, selections });
+ H.runClaudeStream({
+ prompt, tools: H.AGENT_TOOLS, model, resume: job.sessionId,
+ onMessage: (msg) => handleStreamMsg(job, msg),
+ onStderr: (t) => { const s = t.trim(); if (s) H.emit(job, { kind: "tool", text: s.slice(0, 200) }); },
+ onClose: async (code) => {
+ if (code !== 0) {
+ H.emit(job, { type: "error", text: "The agent stopped unexpectedly. Nothing was changed." });
+ H.emit(job, { type: "done" });
+ job.done = true;
+ H.state.busy = false;
+ return;
+ }
+ await buildPreview(job);
+ H.state.busy = false;
+ },
+ onError: (err) => {
+ H.emit(job, { type: "error", text: `Could not start the agent: ${err.message}` });
+ H.emit(job, { type: "done" });
+ job.done = true;
+ H.state.busy = false;
+ },
+ });
+}
+
+export function register(host) {
+ H = host;
+
+ host.route("POST", "/run", async (req, res) => {
+ if (H.state.busy) return H.json(res, 429, { error: "A change is already in progress — let it finish." });
+ const body = await H.readBody(req);
+ const message = String(body.message || "").trim();
+ if (!message) return H.json(res, 400, { error: "Say what you'd like changed." });
+ const env = H.loadEnv();
+ if (!env.CLAUDE_CODE_OAUTH_TOKEN) return H.json(res, 401, { error: "No Claude token set. Add one with the 🔑 button." });
+
+ let job = body.conversationId && H.jobs.get(body.conversationId);
+ if (job) { job.done = false; job.events = []; job.jobId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`; }
+ else job = H.newJob(body.page);
+
+ H.state.busy = true;
+ runAgent(job, { message, page: body.page, selections: body.selections, model: body.model });
+ H.json(res, 200, { conversationId: job.conversationId });
+ });
+
+ host.route("ANY", "/stream", (req, res, { url }) => {
+ const id = url.searchParams.get("conversationId");
+ const job = id && H.jobs.get(id);
+ if (!job) return H.json(res, 404, { error: "unknown conversation" });
+ res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" });
+ for (const ev of job.events) res.write(`data: ${JSON.stringify(ev)}\n\n`);
+ if (job.done) return res.end();
+ job.clients.add(res);
+ req.on("close", () => job.clients.delete(res));
+ });
+
+ host.route("POST", "/publish", async (req, res) => {
+ if (H.state.busy) return H.json(res, 429, { error: "Busy — try again in a moment." });
+ const { conversationId } = await H.readBody(req);
+ const job = conversationId && H.jobs.get(conversationId);
+ if (!job) return H.json(res, 404, { error: "Nothing to publish." });
+ H.state.busy = true;
+ const built = await H.build({});
+ if (!built.ok) { H.state.busy = false; return H.json(res, 500, { error: "Build failed — not published." }); }
+ await H.git(["add", "--", ...H.gitScope]);
+ const summary = (job.page && job.page !== "/" ? job.page : "homepage");
+ await H.git(["commit", "-q", "-m", `console: edit ${summary}\n\n[published via astroagent console]`]);
+ H.rmSync(H.join(H.PREVIEW_DIR, job.jobId), { recursive: true, force: true });
+ H.state.busy = false;
+ H.json(res, 200, { ok: true, live: job.page || "/" });
+ });
+
+ // Rebuild the live site after DB-driven content edits (projects gallery, etc.).
+ host.route("POST", "/rebuild", async (req, res) => {
+ if (H.state.busy) return H.json(res, 429, { error: "Busy — try again in a moment." });
+ H.state.busy = true;
+ const built = await H.build({});
+ if (!built.ok) { H.state.busy = false; return H.json(res, 500, { error: "Build failed." }); }
+ await H.git(["add", "--", "app/public/media", ...H.gitScope]);
+ await H.git(["commit", "-q", "-m", "console: content update"]); // ok if nothing to commit
+ H.state.busy = false;
+ H.json(res, 200, { ok: true });
+ });
+
+ host.route("POST", "/discard", async (req, res) => {
+ if (H.state.busy) return H.json(res, 429, { error: "Busy — try again in a moment." });
+ const { conversationId } = await H.readBody(req);
+ const job = conversationId && H.jobs.get(conversationId);
+ H.state.busy = true;
+ await H.git(["checkout", "--", ...H.gitScope]);
+ await H.git(["clean", "-fd", ...H.gitScope]);
+ if (job) H.rmSync(H.join(H.PREVIEW_DIR, job.jobId), { recursive: true, force: true });
+ H.state.busy = false;
+ if (job) H.jobs.delete(job.conversationId);
+ H.json(res, 200, { ok: true });
+ });
+}
diff --git a/agents/console/agents/project-builder.mjs b/agents/console/agents/project-builder.mjs
new file mode 100644
index 0000000..96d3ad6
--- /dev/null
+++ b/agents/console/agents/project-builder.mjs
@@ -0,0 +1,145 @@
+/**
+ * Project builder — one-shot copy drafting and full structured-project authoring
+ * for the admin's New Project form. Returns JSON to the admin UI (which persists
+ * it via the PHP API); never mutates the repo. Owns: /draft, /build-project.
+ */
+
+let H;
+
+const MEDIA_RE = /^\/media\/[\w.-]+$/;
+const MAX_BUILD_IMAGES = 8;
+
+function stripFence(t) {
+ const m = String(t).match(/```(?:json)?\s*([\s\S]*?)```/);
+ return (m ? m[1] : t).trim();
+}
+
+// Turn a rough draft + prompt into polished project copy. No tools.
+async function draftCopy({ name, category, draft, prompt }) {
+ const ask = [
+ "You are writing copy for Carlos Arias's portfolio (carlosarias.co) — an Agentic AI & Automation Engineer who builds for law firms and service businesses.",
+ "Voice: precise, understated, confident. No hype, no buzzwords, no exclamation marks. Write a project case study.",
+ "",
+ `Project name: ${name}`,
+ category ? `Category: ${category}` : "",
+ draft ? `The author's rough draft / notes:\n${draft}` : "",
+ prompt ? `The author's instructions:\n${prompt}` : "",
+ "",
+ "Return ONLY a JSON object (no markdown fence, no commentary) with these string keys:",
+ '- "summary": one sentence (<=160 chars) for the projects list.',
+ '- "lede": one punchy opening line for the top of the project page.',
+ '- "body": the case study in Markdown. Use ## for section headings and - for bullets. Open with a short overview, and where it fits include a "## Highlights" bulleted section. 150-350 words.',
+ ].filter(Boolean).join("\n");
+
+ const { ok, result } = await H.runClaudeJson({ prompt: ask, tools: "" });
+ if (!ok) return { ok: false };
+ try { const obj = JSON.parse(stripFence(result)); return { ok: true, summary: obj.summary || "", lede: obj.lede || "", body: obj.body || "" }; }
+ catch { return { ok: false }; }
+}
+
+// Read the uploaded images and author a COMPLETE cja_projects record. Read-only.
+async function buildProject({ name, category, draft, prompt, images, instagram }) {
+ const srcs = (Array.isArray(images) ? images : [])
+ .map((im) => String(im?.src || ""))
+ .filter((s) => MEDIA_RE.test(s));
+ const useSrcs = srcs.slice(0, MAX_BUILD_IMAGES);
+ if (srcs.length > MAX_BUILD_IMAGES) console.log(`[build-project] capping images ${srcs.length} -> ${MAX_BUILD_IMAGES}`);
+ const paths = useSrcs.map((s) => "app/public" + s);
+ const imageLines = paths.length ? paths.map((p, i) => ` ${i + 1}. ${p}`).join("\n") : "";
+
+ const ask = [
+ "You are the Web Designer for Carlos Arias's portfolio (carlosarias.co) — an Agentic AI & Automation Engineer who builds for law firms and service businesses.",
+ "Your job: turn the brief below into a COMPLETE project case study for a fixed, on-brand page template. You author STRUCTURED CONTENT, not HTML or layout.",
+ "",
+ "Brand (from brand/BRAND.md — honor it): sumi-e, ink on washi paper, a single vermillion seal, restraint. Voice is precise, understated, confident — no hype, no buzzwords, no exclamation marks. Never describe colours or layout; the template owns all styling.",
+ "",
+ "The page auto-renders these sections from the fields you return — populate the ones the brief supports, leave the rest empty:",
+ "- header: title + one-line lede",
+ "- facts rail: kind (project type), period (timeline label), role",
+ "- metrics: a few outcome stats, each {value, label, note?}",
+ "- gallery: your caption for each image below",
+ "- body: the case study",
+ "- skills: grouped disciplines, each {group, items[]}",
+ "- stack: technologies used, as plain strings",
+ "- categories: 1-3 short tags",
+ "",
+ `Project name: ${name}`,
+ category ? `Category hint: ${category}` : "",
+ instagram ? "There is an Instagram reel for this project." : "",
+ draft ? `The author's rough draft / notes:\n${draft}` : "",
+ prompt ? `The author's special instructions (follow these):\n${prompt}` : "",
+ "",
+ paths.length
+ ? `Uploaded images — READ each file and caption it from what is ACTUALLY shown. Keep captions short and specific; do not invent UI or content that isn't visible:\n${imageLines}`
+ : "No images were uploaded.",
+ "",
+ "Body rules: Markdown only, using ONLY ## / ### headings, paragraphs, - bullet lists, and **bold**. No images, no HTML, no tables. Open with a short overview; where it fits include a '## Highlights' bulleted section. 150-350 words.",
+ "",
+ "Return ONLY a JSON object (no markdown fence, no commentary) with these keys:",
+ '- "summary": one sentence (<=160 chars) for the projects list.',
+ '- "lede": one punchy opening line.',
+ '- "body": the Markdown case study.',
+ '- "kind": short project type, e.g. "Website" or "SaaS / Media" (or "").',
+ '- "period": a timeline label, e.g. "2024 — present" (or "").',
+ '- "role": Carlos\'s role on the project (or "").',
+ '- "categories": array of 1-3 short strings.',
+ '- "stack": array of technology strings.',
+ '- "skills": array of {"group": string, "items": [string, ...]}.',
+ '- "metrics": array of {"value": string, "label": string, "note"?: string}. Use an empty array if the brief has no real numbers — do NOT invent metrics.',
+ `- "captions": array of exactly ${paths.length} strings, one per uploaded image IN ORDER.`,
+ `- "alts": array of exactly ${paths.length} short alt-text strings, one per image IN ORDER.`,
+ ].filter(Boolean).join("\n");
+
+ const { ok, result } = await H.runClaudeJson({ prompt: ask, tools: "Read" });
+ if (!ok) return { ok: false };
+ try {
+ const obj = JSON.parse(stripFence(result));
+ const caps = Array.isArray(obj.captions) ? obj.captions : [];
+ const alts = Array.isArray(obj.alts) ? obj.alts : [];
+ const gallery = useSrcs.map((src, i) => ({
+ src, alt: String(alts[i] || "").slice(0, 200), caption: String(caps[i] || "").slice(0, 120),
+ }));
+ const strArr = (a) => (Array.isArray(a) ? a.map((x) => String(x)).filter(Boolean) : []);
+ return {
+ ok: true,
+ summary: obj.summary || "", lede: obj.lede || "", body: obj.body || "",
+ kind: obj.kind || "", period: obj.period || "", role: obj.role || "",
+ categories: strArr(obj.categories), stack: strArr(obj.stack),
+ skills: Array.isArray(obj.skills) ? obj.skills : [],
+ metrics: Array.isArray(obj.metrics) ? obj.metrics : [],
+ gallery,
+ };
+ } catch { return { ok: false }; }
+}
+
+export function register(host) {
+ H = host;
+
+ host.route("POST", "/draft", async (req, res) => {
+ if (H.state.busy) return H.json(res, 429, { error: "Busy — try again in a moment." });
+ const body = await H.readBody(req);
+ const name = String(body.name || "").trim();
+ if (!name) return H.json(res, 400, { error: "Add a project name first." });
+ const env = H.loadEnv();
+ if (!env.CLAUDE_CODE_OAUTH_TOKEN) return H.json(res, 401, { error: "No Claude token set." });
+ H.state.busy = true;
+ const d = await draftCopy(body);
+ H.state.busy = false;
+ if (!d.ok) return H.json(res, 500, { error: "Couldn't generate a draft — try again." });
+ H.json(res, 200, d);
+ });
+
+ host.route("POST", "/build-project", async (req, res) => {
+ if (H.state.busy) return H.json(res, 429, { error: "Busy — try again in a moment." });
+ const body = await H.readBody(req);
+ const name = String(body.name || "").trim();
+ if (!name) return H.json(res, 400, { error: "Add a project name first." });
+ const env = H.loadEnv();
+ if (!env.CLAUDE_CODE_OAUTH_TOKEN) return H.json(res, 401, { error: "No Claude token set." });
+ H.state.busy = true;
+ const d = await buildProject(body);
+ H.state.busy = false;
+ if (!d.ok) return H.json(res, 500, { error: "The Web Designer couldn't finish — try again." });
+ H.json(res, 200, d);
+ });
+}
diff --git a/agents/console/agents/qa.mjs b/agents/console/agents/qa.mjs
new file mode 100644
index 0000000..dc00f28
--- /dev/null
+++ b/agents/console/agents/qa.mjs
@@ -0,0 +1,235 @@
+/**
+ * QA agent — a deterministic HTTP crawler that tests the live static site (links,
+ * images, forms, API, SEO/meta, a11y), stores findings via the qa-*.php CLI, and
+ * a thin LLM step writes a summary. Read-only against the site. Auto-fixes safe
+ * findings by kicking the Web Designer queue (host.drainTasks). Owns: /qa/run +
+ * an in-process heartbeat.
+ */
+
+import { writeFileSync, rmSync } from "node:fs";
+
+let H;
+
+const QA_BASE = "https://carlosarias.co";
+const QA_UA = "Mozilla/5.0 (compatible; CarlosAriasQA/1.0; +https://carlosarias.co)";
+const QA_STATIC_ROUTES = [
+ "/", "/about", "/services", "/services/website-design",
+ "/projects", "/blog", "/contact", "/changelog", "/resume", "/faq",
+];
+const QA_AUTOFIX = (process.env.QA_AUTOFIX ?? "1") !== "0";
+const QA_HEARTBEAT_MIN = Number(process.env.QA_HEARTBEAT_MIN || 60);
+let qaRunning = false;
+
+async function probe(url, { method = "GET", readBody = false, timeout = 12000, headers = {}, body = null } = {}) {
+ const ctrl = new AbortController();
+ const t = setTimeout(() => ctrl.abort(), timeout);
+ try {
+ const r = await fetch(url, { method, redirect: "follow", signal: ctrl.signal, headers: { "user-agent": QA_UA, ...headers }, body });
+ let text = null;
+ if (readBody) text = await r.text();
+ else { try { await r.body?.cancel(); } catch {} }
+ return { status: r.status, ok: r.ok, finalUrl: r.url, text };
+ } catch (e) {
+ return { status: 0, ok: false, error: e.name === "AbortError" ? "timeout" : (e.message || "network error") };
+ } finally { clearTimeout(t); }
+}
+
+async function pMap(items, concurrency, fn) {
+ const out = []; let i = 0;
+ const workers = Array.from({ length: Math.min(concurrency, items.length || 1) }, async () => {
+ while (i < items.length) { const idx = i++; out[idx] = await fn(items[idx], idx); }
+ });
+ await Promise.all(workers);
+ return out;
+}
+
+const qaNorm = (p) => { p = String(p).split("#")[0].split("?")[0]; if (p.length > 1) p = p.replace(/\/+$/, ""); return p || "/"; };
+const qaAbs = (href, pagePath) => { try { return new URL(href, QA_BASE + pagePath).href; } catch { return null; } };
+const qaGrabAll = (re, html) => [...String(html).matchAll(re)].map((m) => m[1]);
+const qaFirst = (re, html) => { const m = String(html).match(re); return m ? m[1].trim() : ""; };
+function qaAltIssues(html) {
+ const out = [];
+ for (const m of String(html).matchAll(/
]*>/gi)) {
+ const tag = m[0];
+ const src = (tag.match(/\bsrc=["']([^"']+)["']/i) || [])[1];
+ if (!src) continue;
+ const withVal = tag.match(/\salt\s*=\s*["']([^"']*)["']/i);
+ if (withVal) { if (withVal[1].trim() === "") out.push({ src, kind: "empty" }); }
+ else if (/\salt(\s|>|\/)/i.test(tag)) out.push({ src, kind: "empty" });
+ else out.push({ src, kind: "missing" });
+ }
+ return out;
+}
+
+async function runQa() {
+ const findings = [];
+ const add = (check_type, severity, url, detail, fix_hint = null) => findings.push({ check_type, severity, url, detail, fix_hint });
+
+ let dynamic = [];
+ try { dynamic = JSON.parse((await H.phpCli(["api/cli/qa-routes.php"])).out || "[]"); } catch {}
+ const routes = [...new Set([...QA_STATIC_ROUTES, ...dynamic].map(qaNorm))];
+
+ const pages = await pMap(routes, 6, async (path) => ({ path, r: await probe(QA_BASE + path, { readBody: true }) }));
+
+ const fetched = new Map();
+ const titles = new Map();
+ const internal = new Set();
+ const images = new Set();
+ const external = new Map();
+
+ for (const { path, r } of pages) {
+ fetched.set(path, r.ok);
+ if (!r.ok) {
+ add("page", "error", path, `Returns ${r.status || r.error} instead of 200.`,
+ `${path} returns ${r.status || r.error} instead of 200. Investigate why the page fails to render and fix it.`);
+ continue;
+ }
+ const html = r.text || "";
+ const title = qaFirst(/
]*>([^<]*)<\/title>/i, html);
+ const desc = qaFirst(/]+name=["']description["'][^>]+content=["']([^"']*)["']/i, html);
+ const canon = qaFirst(/]+rel=["']canonical["'][^>]+href=["']([^"']*)["']/i, html);
+ if (!title) add("seo", "warning", path, "Missing .", `${path} has no . Add a page-specific title via its BaseLayout props.`);
+ else { if (!titles.has(title)) titles.set(title, []); titles.get(title).push(path); }
+ if (!desc) add("seo", "warning", path, "Missing meta description.", `${path} has no meta description. Add a page-specific description via its BaseLayout props.`);
+ if (canon) { try { const h = new URL(canon).host; if (h && h !== "carlosarias.co") add("seo", "error", path, `Canonical points to ${h}.`, `${path} canonical points to ${h} instead of carlosarias.co. Fix the site URL / canonical.`); } catch {} }
+ if (!/]+lang=/i.test(html)) add("a11y", "warning", path, " has no lang attribute.", `${path} tag has no lang attribute. Add lang="en".`);
+
+ for (const href of qaGrabAll(/]*\bhref=["']([^"']+)["']/gi, html)) {
+ if (/^(mailto:|tel:|javascript:|#|data:)/i.test(href)) continue;
+ const u = qaAbs(href, path); if (!u) continue;
+ const noHash = u.split("#")[0];
+ if (noHash.startsWith(QA_BASE)) internal.add(noHash);
+ else if (/^https?:\/\//i.test(noHash) && !external.has(noHash)) external.set(noHash, path);
+ }
+ for (const src of qaGrabAll(/
]*\bsrc=["']([^"']+)["']/gi, html)) { const u = qaAbs(src, path); if (u && /^https?:/i.test(u)) images.add(u.split("#")[0]); }
+ for (const a of qaAltIssues(html)) {
+ if (a.kind === "empty") add("a11y", "warning", path, `Empty alt: ${a.src}`,
+ `On ${path}, the image "${a.src}" has an empty alt attribute. If it conveys meaning (a photo, cover, or screenshot), add descriptive alt text that explains what it shows; leave it empty only if it is purely decorative.`);
+ else add("a11y", "warning", path, `Missing alt: ${a.src}`,
+ `On ${path}, the image "${a.src}" has no alt attribute. Add descriptive alt text that explains what it shows.`);
+ }
+ }
+
+ for (const [title, paths] of titles) {
+ if (paths.length > 1) add("seo", "warning", paths.join(", "), `${paths.length} pages share the title "${title}".`,
+ `These pages share one ("${title}"): ${paths.join(", ")}. Give each a distinct, page-specific title.`);
+ }
+
+ const internalPaths = [...new Set([...internal].map((u) => qaNorm(u.replace(QA_BASE, "") || "/")))];
+ const toCheck = internalPaths.filter((p) => !fetched.has(p));
+ await pMap(toCheck, 8, async (p) => {
+ const r = await probe(QA_BASE + p, {});
+ if (!r.ok) add("link", "error", p, `Broken internal link (${r.status || r.error}).`,
+ `An internal link points to ${p}, which returns ${r.status || r.error}. Find that link in the page source and fix the URL or remove the link.`);
+ });
+
+ await pMap([...images], 8, async (u) => {
+ const r = await probe(u, {});
+ if (!r.ok) add("image", "error", u, `Image returns ${r.status || r.error}.`,
+ `The image ${u} returns ${r.status || r.error}. Fix the image path or replace the image.`);
+ });
+
+ await pMap([...external.keys()], 6, async (u) => {
+ const r = await probe(u, { method: "GET", timeout: 12000 });
+ const clearlyBad = r.status === 404 || r.status === 410 || (r.status === 0 && r.error && r.error !== "timeout");
+ if (clearlyBad) add("external", "warning", u, `External link may be broken (${r.status || r.error}); found on ${external.get(u)}.`,
+ `The external link ${u} (on ${external.get(u)}) appears broken (${r.status || r.error}). Verify it and update or remove it.`);
+ });
+
+ const cHeaders = { "content-type": "application/json", origin: QA_BASE, referer: QA_BASE + "/contact" };
+ const hp = await probe(QA_BASE + "/api/contact/submit", {
+ method: "POST", readBody: true, headers: cHeaders,
+ body: JSON.stringify({ name: "QA Bot", email: "qa@carlosarias.co", subject: "other", message: "QA honeypot probe — please ignore.", company: "qa-honeypot" }),
+ });
+ if (hp.status !== 200) add("form", "error", "/api/contact/submit", `Contact honeypot probe returned ${hp.status || hp.error} (expected 200).`,
+ `POST /api/contact/submit returned ${hp.status || hp.error} instead of 200 for a probe. The contact form endpoint may be broken — check api/public/controllers/contact.php.`);
+ const val = await probe(QA_BASE + "/api/contact/submit", {
+ method: "POST", readBody: true, headers: cHeaders,
+ body: JSON.stringify({ name: "QA", email: "qa@carlosarias.co", subject: "other", message: "hi" }),
+ });
+ if (val.status !== 422) add("form", "warning", "/api/contact/submit", `Validation probe returned ${val.status || val.error} (expected 422 for a too-short message).`,
+ `POST /api/contact/submit did not reject an invalid submission (got ${val.status || val.error}, expected 422). Server-side validation may be off.`);
+
+ const health = await probe(QA_BASE + "/api/health", { readBody: true });
+ let dbOk = false; try { const j = JSON.parse(health.text || "{}"); dbOk = (j.data?.db ?? j.db) === "connected"; } catch {}
+ if (health.status !== 200 || !dbOk) add("health", "error", "/api/health", `Status ${health.status || health.error}, db ${dbOk ? "connected" : "not connected"}.`,
+ `/api/health returned ${health.status || health.error}${dbOk ? "" : " and the database is not connected"}. The API or database may be down.`);
+
+ const sm = await probe(QA_BASE + "/sitemap.xml", { readBody: true });
+ if (sm.ok) {
+ const locs = new Set(qaGrabAll(/([^<]+)<\/loc>/gi, sm.text || "").map((l) => qaNorm(l.replace(QA_BASE, ""))));
+ for (const p of routes) if (!locs.has(p)) add("sitemap", "warning", p, "Not listed in sitemap.xml.",
+ `${p} is not in sitemap.xml. Add it in app/src/pages/sitemap.xml.js so search engines can find it.`);
+ } else {
+ add("sitemap", "warning", "/sitemap.xml", `sitemap.xml returned ${sm.status || sm.error}.`, `/sitemap.xml is unreachable (${sm.status || sm.error}). Check app/src/pages/sitemap.xml.js.`);
+ }
+
+ const counts = { error: 0, warning: 0, info: 0, pages: pages.length, links: toCheck.length, images: images.size, external: external.size };
+ for (const f of findings) counts[f.severity] = (counts[f.severity] || 0) + 1;
+ return { findings, counts };
+}
+
+async function qaTriage(findings, counts) {
+ const top = [
+ ...findings.filter((f) => f.severity === "error").slice(0, 12),
+ ...findings.filter((f) => f.severity === "warning").slice(0, 12),
+ ];
+ const lines = top.map((f) => `- [${f.severity}] ${f.check_type} ${f.url}: ${f.detail}`).join("\n") || "(no issues found)";
+ const fallback = `${counts.error} error(s) and ${counts.warning} warning(s) across ${counts.pages} pages.`;
+ const ask = [
+ "You are the QA agent for carlosarias.co. A crawler just tested the live site. Consult your `qa` skill.",
+ `Counts: ${counts.error} errors, ${counts.warning} warnings across ${counts.pages} pages.`,
+ "Top findings:", lines,
+ "",
+ "Write a 2-4 sentence plain-English summary for the site owner: overall health, the most important things to fix first, and whether anything is urgent. No preamble — just the summary.",
+ ].join("\n");
+ const { ok, result } = await H.runClaudeJson({ prompt: ask, tools: "Read Skill" });
+ const s = ok && typeof result === "string" ? result.trim().slice(0, 800) : "";
+ return s || fallback;
+}
+
+async function runQaFlow(trigger) {
+ if (qaRunning) return;
+ qaRunning = true;
+ let runId = 0;
+ try {
+ const start = await H.phpCli(["api/cli/qa-start.php", `--trigger=${trigger}`]);
+ runId = JSON.parse(start.out || "{}").run_id || 0;
+ if (!runId) throw new Error("could not open a QA run");
+ const { findings, counts } = await runQa();
+ const summary = (trigger === "manual" || counts.error > 0)
+ ? await qaTriage(findings, counts)
+ : `${counts.error} error(s) and ${counts.warning} warning(s) across ${counts.pages} pages.`;
+ const tmp = `/tmp/qa-${runId}.json`;
+ writeFileSync(tmp, JSON.stringify(findings));
+ await H.phpCli(["api/cli/qa-finish.php", `--run=${runId}`, "--status=done", `--summary=${summary}`, `--counts=${JSON.stringify(counts)}`, `--findings-file=${tmp}`]);
+ rmSync(tmp, { force: true });
+
+ if (QA_AUTOFIX) {
+ const af = await H.phpCli(["api/cli/qa-autofix.php", `--run=${runId}`]);
+ let queued = 0; try { queued = JSON.parse(af.out || "{}").queued || 0; } catch {}
+ if (queued > 0 && H.drainTasks) H.drainTasks(); // fire-and-forget
+ }
+ } catch (e) {
+ if (runId) await H.phpCli(["api/cli/qa-finish.php", `--run=${runId}`, "--status=failed", `--summary=QA run failed: ${String((e && e.message) || e).slice(0, 180)}`]);
+ } finally {
+ qaRunning = false;
+ }
+}
+
+export function register(host) {
+ H = host;
+
+ host.route("POST", "/qa/run", async (req, res) => {
+ if (qaRunning) return H.json(res, 429, { error: "A QA run is already in progress." });
+ const body = await H.readBody(req);
+ const trigger = body.trigger === "scheduled" ? "scheduled" : "manual";
+ runQaFlow(trigger); // fire-and-forget
+ H.json(res, 200, { ok: true });
+ });
+
+ if (QA_HEARTBEAT_MIN > 0) {
+ host.everyMinutes(QA_HEARTBEAT_MIN, () => runQaFlow("scheduled"), { bootDelayMs: 90_000 });
+ console.log(`[qa] heartbeat every ${QA_HEARTBEAT_MIN} min`);
+ }
+}
diff --git a/agents/console/agents/web-designer.mjs b/agents/console/agents/web-designer.mjs
new file mode 100644
index 0000000..bb91a96
--- /dev/null
+++ b/agents/console/agents/web-designer.mjs
@@ -0,0 +1,157 @@
+/**
+ * Web Designer agent — a durable design queue (cja_tasks) drained one task at a
+ * time. Each task runs the full-builder agent on any page (skills + brief), then
+ * auto-publishes: build must pass, then a scoped git commit; a failing task
+ * reverts itself. Owns: /tasks/run. Exposes host.drainTasks for QA autofix.
+ */
+
+let H;
+let draining = false;
+
+async function claimNextTask() {
+ const { out } = await H.phpCli(["api/cli/tasks-next.php"]);
+ try { const t = JSON.parse((out || "{}").trim() || "{}"); return t && t.task_id ? t : null; }
+ catch { return null; }
+}
+async function finishTask(id, status, result) {
+ await H.phpCli(["api/cli/tasks-finish.php", `--id=${id}`, `--status=${status}`, `--result=${JSON.stringify(result)}`]);
+}
+
+function liveUrl(target) {
+ if (!target) return "/";
+ if (target.startsWith("new:")) return "/" + target.slice(4).replace(/^\/+/, "");
+ return target;
+}
+function nowStamp() {
+ const d = new Date();
+ const p = (n) => String(n).padStart(2, "0");
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
+}
+
+function buildDesignPrompt(task) {
+ let assets = { images: [], videos: [] };
+ try { assets = JSON.parse(task.assets || "{}") || {}; } catch {}
+ const images = Array.isArray(assets.images) ? assets.images : [];
+ const videos = Array.isArray(assets.videos) ? assets.videos : [];
+ const target = task.target_page || "/";
+ const isNew = target.startsWith("new:");
+ const slug = isNew ? target.slice(4).replace(/^\/+/, "") : "";
+
+ const p = [];
+ p.push(
+ "You are the Web Designer for the Carlos Arias website (carlosarias.co) — an Astro + Tailwind v4 static site.",
+ "You are a real designer: make considered, on-brand design decisions, not just literal edits.",
+ "",
+ "Before you start, consult your design skills and APPLY them: read .claude/skills/brand/SKILL.md and .claude/skills/ui-ux/SKILL.md.",
+ "The brand is sumi-e — ink on washi paper, a single vermillion seal, restraint, near-square 2px radii. Reuse the existing .ca-* classes and --ca-* tokens in app/src/styles.css and match nearby components; do not invent new one-off styles or add a second accent colour.",
+ "Do NOT run builds or git — the console builds and publishes for you.",
+ "",
+ );
+ if (isNew) {
+ p.push(
+ `TASK: create a NEW page at /${slug}.`,
+ `- Create app/src/pages/${slug}.astro using BaseLayout and existing .ca-* section patterns (study app/src/pages/about.astro and services.astro for structure).`,
+ "- Register it in the nav: add it to the nav array in app/src/components/Header.astro AND the footer links in app/src/components/Footer.astro.",
+ "",
+ );
+ } else {
+ p.push(
+ `TASK: work on the existing page ${target}.`,
+ "- Find its source under app/src/pages (Glob/Grep). Edit that file and any components it uses.",
+ "",
+ );
+ }
+ p.push("What to do:", task.prompt || "(no instructions given)", "");
+ if (task.draft && String(task.draft).trim()) {
+ p.push("Draft content to work from (polish it, don't paste it verbatim):", task.draft, "");
+ }
+ if (images.length) {
+ p.push("Images you may use — Read each to see what it shows, then place it with its /media/... src and a real alt:");
+ for (const im of images) p.push(`- ${im.url} (on disk: app/public${im.url})${im.alt ? ` — hint: ${im.alt}` : ""}`);
+ p.push("");
+ }
+ if (videos.length) {
+ p.push("Short video links to embed where they fit (e.g. an Instagram reel — responsive 9:16, no autoplay sound):");
+ for (const v of videos) p.push(`- ${v}`);
+ p.push("");
+ }
+ p.push(
+ "",
+ "After you finish the change, log it to the public changelog using your `changelog` skill:",
+ "- Add ONE entry to the $entries array in api/cli/seed-changelog.php, in the site's visitor-facing voice.",
+ `- Use the timestamp '${nowStamp()}' and attribute it to 'Website Designer Agent' (the 5th array element).`,
+ "- Choose the right type (added / updated / fixed / removed).",
+ "- Do NOT run the reseed or the build — the console does that for you.",
+ "- If you ended up making no change to the site, do not add a changelog entry.",
+ "",
+ "Keep the change scoped to what's asked and leave the working tree with only your intended edits.",
+ );
+ return p.join("\n");
+}
+
+async function runDesignTask(task) {
+ const prompt = buildDesignPrompt(task);
+
+ // Pre-flight: a dirty tree would be swept into this task's scoped commit.
+ // Uploaded assets under app/public/media/ are this task's images and are fine.
+ const pre = await H.git(["status", "--porcelain", "--", ...H.gitScope]);
+ const dirty = pre.tail.split("\n").map((l) => l.slice(3).trim()).filter(Boolean).filter((p) => !p.startsWith("app/public/media/"));
+ if (dirty.length) return { ok: false, error: "Working tree wasn't clean — commit or discard pending changes before running the queue." };
+
+ const { result } = await H.runClaudeJson({ prompt, tools: "Read Write Edit Glob Grep WebSearch Skill" });
+ const summary = typeof result === "string" ? result.trim().slice(0, 500) : "";
+
+ const status = await H.git(["status", "--porcelain", "--", ...H.gitScope]);
+ if (status.tail.trim() === "") {
+ return { ok: true, commit: "", summary: summary || "No changes were needed.", live: liveUrl(task.target_page) };
+ }
+ // The agent logs to the changelog by editing seed-changelog.php; reseed so the
+ // build picks it up (the changelog page reads the DB at build time).
+ if (/seed-changelog\.php/.test(status.tail)) await H.phpCli(["api/cli/seed-changelog.php"]);
+
+ const built = await H.build({});
+ if (!built.ok) {
+ await H.git(["checkout", "--", ...H.gitScope]);
+ await H.git(["clean", "-fd", ...H.gitScope]);
+ return { ok: false, error: "The change broke the build, so it was reverted." };
+ }
+ await H.git(["add", "--", ...H.gitScope]);
+ const title = (task.title || "task").toString().slice(0, 120);
+ await H.git(["commit", "-q", "-m", `web designer: ${title}\n\n[published via task queue]`]);
+ const head = await H.git(["rev-parse", "--short", "HEAD"]);
+ return { ok: true, commit: head.tail.trim(), summary, live: liveUrl(task.target_page) };
+}
+
+// Drain the queue sequentially. Shares host.state.busy; re-entrant-safe.
+async function drainTasks() {
+ if (draining) return;
+ draining = true;
+ try {
+ for (;;) {
+ if (H.state.busy) break; // in-page console mid-edit; a later kick resumes
+ const task = await claimNextTask();
+ if (!task) break;
+ H.state.busy = true;
+ let res;
+ try { res = await runDesignTask(task); }
+ catch (e) { res = { ok: false, error: String((e && e.message) || e) }; }
+ H.state.busy = false;
+ await finishTask(task.task_id, res.ok ? "published" : "failed",
+ res.ok ? { commit: res.commit, summary: res.summary, live: res.live } : { error: res.error || "failed" });
+ }
+ } finally {
+ draining = false;
+ }
+}
+
+export function register(host) {
+ H = host;
+ host.drainTasks = drainTasks; // QA autofix kicks the queue through this
+
+ host.route("POST", "/tasks/run", async (req, res) => {
+ const env = H.loadEnv();
+ if (!env.CLAUDE_CODE_OAUTH_TOKEN) return H.json(res, 401, { error: "No Claude token set." });
+ drainTasks(); // fire-and-forget
+ H.json(res, 200, { ok: true, draining, busy: H.state.busy });
+ });
+}
diff --git a/agents/console/host.mjs b/agents/console/host.mjs
new file mode 100644
index 0000000..212f6a1
--- /dev/null
+++ b/agents/console/host.mjs
@@ -0,0 +1,225 @@
+/**
+ * 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
diff --git a/agents/console/server.mjs b/agents/console/server.mjs
index f1b72d6..07b0d21 100644
--- a/agents/console/server.mjs
+++ b/agents/console/server.mjs
@@ -1,1037 +1,28 @@
#!/usr/bin/env node
/**
- * AstroAgent console runner.
+ * AstroAgent console runner — bootstrap.
*
- * Runs AS the confined `carlos-arias-agent` user (systemd User=), binds
- * 127.0.0.1 only, and is reached exclusively through nginx, which gates every
- * request with auth_request against the PHP admin session. If a request lands
- * here, the operator is already an authenticated admin — this process does not
- * re-check that; its job is orchestration.
+ * Loads the generic host, registers the agent modules, and starts listening.
+ * Runs AS the confined agent user (systemd User=), binds 127.0.0.1 only, and is
+ * reached exclusively through nginx, which gates every request with auth_request
+ * against the PHP admin session — this process does not re-check that.
*
- * Contract (matches app/src/components/DevConsole.astro):
- * GET /devconsole/ping -> { authed, hasToken }
- * POST /devconsole/run {message,page,...} -> { conversationId } (async)
- * GET /devconsole/stream?conversationId=... -> SSE: text|tool|preview|published|error|done
- * POST /devconsole/publish {conversationId} -> promotes preview, git commit
- * POST /devconsole/discard {conversationId} -> reverts working tree
- * POST /devconsole/auth {token} -> update the Claude token
- * POST /devconsole/logout -> (admin logout is PHP; no-op here)
- *
- * Safety model:
- * - The AGENT only edits files (tools: Read/Write/Edit/Glob/Grep/WebSearch,
- * NO Bash). This runner — not the agent — runs every build and git op.
- * - Every /run edits the working tree, then builds an ISOLATED preview to
- * public-preview/. Nothing is live.
- * - /publish builds to public/, verifies the build passed, then git-commits
- * (a rollback point). /discard does `git checkout -- .`.
- * - One job at a time (single admin); a lock serialises run/publish/discard.
+ * The shared runtime lives in ./host.mjs; each capability is a module under
+ * ./agents/ exporting register(host). (Phase 3 will drive this roster from
+ * astroagent.config.json instead of the hardcoded list below.)
*
* 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));
-const REPO = resolve(HERE, "..", ".."); // /www/wwwroot/CarlosAriasPersonal
-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";
-
-// ---- 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 });
-}
-
-// ---- job state ---------------------------------------------------------------
-/** conversationId -> job */
-const jobs = new Map();
-let busy = false; // single-flight lock
-
-function newJob(page) {
- const conversationId = randomUUID();
- const job = {
- conversationId,
- jobId: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`,
- sessionId: null, // claude session, for --resume follow-ups
- page: page || "/",
- events: [], // buffered for late SSE subscribers
- clients: new Set(), // active SSE responses
- 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 {}
- }
-}
-
-// ---- prompt wrapper ----------------------------------------------------------
-function buildPrompt({ message, page, selections }) {
- const parts = [];
- parts.push(
- "You are the editing agent for the Carlos Arias website (an Astro + Tailwind v4 static site).",
- "Make ONLY the change the operator asks for. Keep everything on-brand:",
- "the brand guide is brand/BRAND.md and the design tokens are in app/src/styles.css",
- "(sumi-e — ink on washi paper, one vermillion seal, restraint, near-square radii).",
- "Do NOT run builds or git commands — the console builds and publishes for you.",
- "",
- );
- if (page && page !== "/") {
- parts.push(`The operator is on the page: ${page}`);
- parts.push(
- "Its source is almost certainly under app/src/pages (find it with Glob/Grep).",
- "",
- );
- } else if (page === "/") {
- parts.push("The operator is on the homepage (app/src/pages/index.astro).", "");
- }
- if (Array.isArray(selections) && selections.length) {
- parts.push("They selected these element(s) on the page:");
- for (const s of selections) {
- const tag = s.tag || s.selector || "element";
- parts.push(`- <${tag}>${s.text ? ` — "${String(s.text).slice(0, 80)}"` : ""}${s.comment ? ` — note: ${s.comment}` : ""}`);
- }
- parts.push("");
- }
- parts.push("Request:", message);
- return parts.join("\n");
-}
-
-// ---- run the agent, then build a preview -------------------------------------
-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 || "",
- };
-}
-
-function runAgent(job, { message, page, selections, model }) {
- const prompt = buildPrompt({ message, page, selections });
- const args = [
- "-p", prompt,
- "--output-format", "stream-json",
- "--verbose",
- "--allowedTools", AGENT_TOOLS,
- "--model", model || DEFAULT_MODEL,
- ];
- if (job.sessionId) args.push("--resume", job.sessionId);
-
- 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; }
- handleStreamMsg(job, msg);
- }
- });
- child.stderr.on("data", (d) => {
- const t = d.toString().trim();
- if (t) emit(job, { kind: "tool", text: t.slice(0, 200) });
- });
- child.on("close", async (code) => {
- if (code !== 0) {
- emit(job, { type: "error", text: "The agent stopped unexpectedly. Nothing was changed." });
- emit(job, { type: "done" });
- job.done = true;
- busy = false;
- return;
- }
- await buildPreview(job);
- busy = false;
- });
- child.on("error", (err) => {
- emit(job, { type: "error", text: `Could not start the agent: ${err.message}` });
- emit(job, { type: "done" });
- job.done = true;
- busy = false;
- });
-}
-
-function handleStreamMsg(job, msg) {
- // capture the resumable session id
- if (msg.session_id) job.sessionId = msg.session_id;
- const content = msg?.message?.content;
- if (Array.isArray(content)) {
- for (const block of content) {
- if (block.type === "text" && block.text) emit(job, { kind: "text", text: block.text });
- else if (block.type === "tool_use") {
- const label = block.name === "Edit" || block.name === "Write"
- ? `${block.name} ${shortPath(block.input?.file_path)}`
- : block.name;
- emit(job, { kind: "tool", text: label });
- }
- }
- }
- if (msg.type === "result" && typeof msg.result === "string" && msg.result.trim()) {
- emit(job, { kind: "text", text: msg.result.trim() });
- }
-}
-function shortPath(p) {
- if (!p) return "";
- return String(p).replace(REPO + "/", "");
-}
-
-// ---- builds ------------------------------------------------------------------
-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" }));
- });
-}
-
-async function buildPreview(job) {
- emit(job, { kind: "tool", text: "building preview…" });
- const out = join("..", "public-preview", job.jobId);
- const base = `/_preview/${job.jobId}`;
- const { ok, tail } = await build({ PREVIEW_OUT: out, PREVIEW_BASE: base });
- if (!ok) {
- emit(job, { type: "error", text: "The change broke the build, so it was not applied. Try rephrasing." });
- // revert the agent's edits so the working tree stays clean
- await git(["checkout", "--", "app", "brand", "api/db", "api/cli"]);
- emit(job, { type: "done" });
- job.done = true;
- return;
- }
- const target = job.page && job.page !== "/" ? job.page.replace(/^\//, "") : "";
- emit(job, { type: "preview", url: `${base}/${target}` });
- emit(job, { type: "done" });
- job.done = true;
-}
-
-// ---- git ---------------------------------------------------------------------
-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: "" }));
- });
-}
-
-// ---- one-shot copy drafting --------------------------------------------------
-// Turn a rough draft + prompt into polished project copy. No tools, no file
-// writes, no preview — just returns text for the admin to review before saving.
-function stripFence(t) {
- const m = String(t).match(/```(?:json)?\s*([\s\S]*?)```/);
- return (m ? m[1] : t).trim();
-}
-function draftCopy({ name, category, draft, prompt }) {
- const ask = [
- "You are writing copy for Carlos Arias's portfolio (carlosarias.co) — an Agentic AI & Automation Engineer who builds for law firms and service businesses.",
- "Voice: precise, understated, confident. No hype, no buzzwords, no exclamation marks. Write a project case study.",
- "",
- `Project name: ${name}`,
- category ? `Category: ${category}` : "",
- draft ? `The author's rough draft / notes:\n${draft}` : "",
- prompt ? `The author's instructions:\n${prompt}` : "",
- "",
- "Return ONLY a JSON object (no markdown fence, no commentary) with these string keys:",
- '- "summary": one sentence (<=160 chars) for the projects list.',
- '- "lede": one punchy opening line for the top of the project page.',
- '- "body": the case study in Markdown. Use ## for section headings and - for bullets. Open with a short overview, and where it fits include a "## Highlights" bulleted section. 150-350 words.',
- ].filter(Boolean).join("\n");
-
- return new Promise((resolve) => {
- const args = ["-p", ask, "--output-format", "json", "--model", DEFAULT_MODEL, "--allowedTools", ""];
- const child = spawn(CLAUDE, args, { cwd: REPO, env: agentEnv() });
- let out = "";
- child.stdout.on("data", (d) => (out += d));
- child.on("close", () => {
- try {
- const envelope = JSON.parse(out);
- const text = typeof envelope.result === "string" ? envelope.result : "";
- const obj = JSON.parse(stripFence(text));
- resolve({ ok: true, summary: obj.summary || "", lede: obj.lede || "", body: obj.body || "" });
- } catch { resolve({ ok: false }); }
- });
- child.on("error", () => resolve({ ok: false }));
- });
-}
-
-// ---- Web Designer: build a full structured project from a brief --------------
-// Reads the uploaded images and authors a COMPLETE cja_projects record (copy +
-// skills + metrics + captions). Read-only (no Write/Edit/Bash) — it returns
-// structured data the server validates; it never mutates the repo.
-const MEDIA_RE = /^\/media\/[\w.-]+$/;
-const MAX_BUILD_IMAGES = 8;
-
-function buildProject({ name, category, draft, prompt, images, instagram }) {
- // keep only safe /media/ srcs, map to on-disk paths the agent can Read (cwd = REPO)
- const srcs = (Array.isArray(images) ? images : [])
- .map((im) => String(im?.src || ""))
- .filter((s) => MEDIA_RE.test(s));
- const useSrcs = srcs.slice(0, MAX_BUILD_IMAGES);
- if (srcs.length > MAX_BUILD_IMAGES) {
- console.log(`[build-project] capping images ${srcs.length} -> ${MAX_BUILD_IMAGES} (${srcs.length - MAX_BUILD_IMAGES} not captioned)`);
- }
- const paths = useSrcs.map((s) => "app/public" + s); // /media/x -> app/public/media/x
- const imageLines = paths.length ? paths.map((p, i) => ` ${i + 1}. ${p}`).join("\n") : "";
-
- const ask = [
- "You are the Web Designer for Carlos Arias's portfolio (carlosarias.co) — an Agentic AI & Automation Engineer who builds for law firms and service businesses.",
- "Your job: turn the brief below into a COMPLETE project case study for a fixed, on-brand page template. You author STRUCTURED CONTENT, not HTML or layout.",
- "",
- "Brand (from brand/BRAND.md — honor it): sumi-e, ink on washi paper, a single vermillion seal, restraint. Voice is precise, understated, confident — no hype, no buzzwords, no exclamation marks. Never describe colours or layout; the template owns all styling.",
- "",
- "The page auto-renders these sections from the fields you return — populate the ones the brief supports, leave the rest empty:",
- "- header: title + one-line lede",
- "- facts rail: kind (project type), period (timeline label), role",
- "- metrics: a few outcome stats, each {value, label, note?}",
- "- gallery: your caption for each image below",
- "- body: the case study",
- "- skills: grouped disciplines, each {group, items[]}",
- "- stack: technologies used, as plain strings",
- "- categories: 1-3 short tags",
- "",
- `Project name: ${name}`,
- category ? `Category hint: ${category}` : "",
- instagram ? "There is an Instagram reel for this project." : "",
- draft ? `The author's rough draft / notes:\n${draft}` : "",
- prompt ? `The author's special instructions (follow these):\n${prompt}` : "",
- "",
- paths.length
- ? `Uploaded images — READ each file and caption it from what is ACTUALLY shown. Keep captions short and specific; do not invent UI or content that isn't visible:\n${imageLines}`
- : "No images were uploaded.",
- "",
- "Body rules: Markdown only, using ONLY ## / ### headings, paragraphs, - bullet lists, and **bold**. No images, no HTML, no tables. Open with a short overview; where it fits include a '## Highlights' bulleted section. 150-350 words.",
- "",
- "Return ONLY a JSON object (no markdown fence, no commentary) with these keys:",
- '- "summary": one sentence (<=160 chars) for the projects list.',
- '- "lede": one punchy opening line.',
- '- "body": the Markdown case study.',
- '- "kind": short project type, e.g. "Website" or "SaaS / Media" (or "").',
- '- "period": a timeline label, e.g. "2024 — present" (or "").',
- '- "role": Carlos\'s role on the project (or "").',
- '- "categories": array of 1-3 short strings.',
- '- "stack": array of technology strings.',
- '- "skills": array of {"group": string, "items": [string, ...]}.',
- '- "metrics": array of {"value": string, "label": string, "note"?: string}. Use an empty array if the brief has no real numbers — do NOT invent metrics.',
- `- "captions": array of exactly ${paths.length} strings, one per uploaded image IN ORDER.`,
- `- "alts": array of exactly ${paths.length} short alt-text strings, one per image IN ORDER.`,
- ].filter(Boolean).join("\n");
-
- return new Promise((resolve) => {
- const args = ["-p", ask, "--output-format", "json", "--model", DEFAULT_MODEL, "--allowedTools", "Read"];
- const child = spawn(CLAUDE, args, { cwd: REPO, env: agentEnv() });
- let out = "";
- child.stdout.on("data", (d) => (out += d));
- child.on("close", () => {
- try {
- const envelope = JSON.parse(out);
- const text = typeof envelope.result === "string" ? envelope.result : "";
- const obj = JSON.parse(stripFence(text));
- // Re-zip the gallery server-side: trust OUR srcs, take the model's
- // caption/alt by index. The model never dictates a file path.
- const caps = Array.isArray(obj.captions) ? obj.captions : [];
- const alts = Array.isArray(obj.alts) ? obj.alts : [];
- const gallery = useSrcs.map((src, i) => ({
- src,
- alt: String(alts[i] || "").slice(0, 200),
- caption: String(caps[i] || "").slice(0, 120),
- }));
- const strArr = (a) => (Array.isArray(a) ? a.map((x) => String(x)).filter(Boolean) : []);
- resolve({
- ok: true,
- summary: obj.summary || "",
- lede: obj.lede || "",
- body: obj.body || "",
- kind: obj.kind || "",
- period: obj.period || "",
- role: obj.role || "",
- categories: strArr(obj.categories),
- stack: strArr(obj.stack),
- skills: Array.isArray(obj.skills) ? obj.skills : [],
- metrics: Array.isArray(obj.metrics) ? obj.metrics : [],
- gallery,
- });
- } catch { resolve({ ok: false }); }
- });
- child.on("error", () => resolve({ ok: false }));
- });
-}
-
-// ---- Web Designer task queue -------------------------------------------------
-// A durable design queue (cja_tasks) drained one task at a time. Each task runs
-// the full-builder agent on any page (skills + brief), then auto-publishes:
-// build must pass, then a scoped git commit. A failing task reverts itself and
-// the queue moves on. The runner has no DB driver, so it claims/finishes rows
-// via the api/cli/tasks-*.php helpers.
-const GIT_SCOPE = ["app", "brand", "api/db", "api/cli"];
-let draining = false;
-
-function phpCli(args) {
- return new Promise((resolve) => {
- 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", () => resolve({ out, err }));
- child.on("error", () => resolve({ out: "", err: "php spawn failed" }));
- });
-}
-
-async function claimNextTask() {
- const { out } = await phpCli(["api/cli/tasks-next.php"]);
- try { const t = JSON.parse((out || "{}").trim() || "{}"); return t && t.task_id ? t : null; }
- catch { return null; }
-}
-async function finishTask(id, status, result) {
- await phpCli(["api/cli/tasks-finish.php", `--id=${id}`, `--status=${status}`, `--result=${JSON.stringify(result)}`]);
-}
-
-function liveUrl(target) {
- if (!target) return "/";
- if (target.startsWith("new:")) return "/" + target.slice(4).replace(/^\/+/, "");
- return target;
-}
-
-function nowStamp() {
- const d = new Date();
- const p = (n) => String(n).padStart(2, "0");
- return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
-}
-
-function buildDesignPrompt(task) {
- let assets = { images: [], videos: [] };
- try { assets = JSON.parse(task.assets || "{}") || {}; } catch {}
- const images = Array.isArray(assets.images) ? assets.images : [];
- const videos = Array.isArray(assets.videos) ? assets.videos : [];
- const target = task.target_page || "/";
- const isNew = target.startsWith("new:");
- const slug = isNew ? target.slice(4).replace(/^\/+/, "") : "";
-
- const p = [];
- p.push(
- "You are the Web Designer for the Carlos Arias website (carlosarias.co) — an Astro + Tailwind v4 static site.",
- "You are a real designer: make considered, on-brand design decisions, not just literal edits.",
- "",
- "Before you start, consult your design skills and APPLY them: read .claude/skills/brand/SKILL.md and .claude/skills/ui-ux/SKILL.md.",
- "The brand is sumi-e — ink on washi paper, a single vermillion seal, restraint, near-square 2px radii. Reuse the existing .ca-* classes and --ca-* tokens in app/src/styles.css and match nearby components; do not invent new one-off styles or add a second accent colour.",
- "Do NOT run builds or git — the console builds and publishes for you.",
- "",
- );
- if (isNew) {
- p.push(
- `TASK: create a NEW page at /${slug}.`,
- `- Create app/src/pages/${slug}.astro using BaseLayout and existing .ca-* section patterns (study app/src/pages/about.astro and services.astro for structure).`,
- "- Register it in the nav: add it to the nav array in app/src/components/Header.astro AND the footer links in app/src/components/Footer.astro.",
- "",
- );
- } else {
- p.push(
- `TASK: work on the existing page ${target}.`,
- "- Find its source under app/src/pages (Glob/Grep). Edit that file and any components it uses.",
- "",
- );
- }
- p.push("What to do:", task.prompt || "(no instructions given)", "");
- if (task.draft && String(task.draft).trim()) {
- p.push("Draft content to work from (polish it, don't paste it verbatim):", task.draft, "");
- }
- if (images.length) {
- p.push("Images you may use — Read each to see what it shows, then place it with its /media/... src and a real alt:");
- for (const im of images) {
- p.push(`- ${im.url} (on disk: app/public${im.url})${im.alt ? ` — hint: ${im.alt}` : ""}`);
- }
- p.push("");
- }
- if (videos.length) {
- p.push("Short video links to embed where they fit (e.g. an Instagram reel — responsive 9:16, no autoplay sound):");
- for (const v of videos) p.push(`- ${v}`);
- p.push("");
- }
- p.push(
- "",
- "After you finish the change, log it to the public changelog using your `changelog` skill:",
- "- Add ONE entry to the $entries array in api/cli/seed-changelog.php, in the site's visitor-facing voice.",
- `- Use the timestamp '${nowStamp()}' and attribute it to 'Website Designer Agent' (the 5th array element).`,
- "- Choose the right type (added / updated / fixed / removed).",
- "- Do NOT run the reseed or the build — the console does that for you.",
- "- If you ended up making no change to the site, do not add a changelog entry.",
- "",
- "Keep the change scoped to what's asked and leave the working tree with only your intended edits.",
- );
- return p.join("\n");
-}
-
-async function runDesignTask(task) {
- const prompt = buildDesignPrompt(task);
-
- // Pre-flight: the auto-publish commit is scoped to GIT_SCOPE, so a dirty tree
- // would get swept into this task's commit. Refuse rather than clobber WIP.
- // Uploaded assets under app/public/media/ are expected (they're this task's
- // images) and get committed with it, so they don't count as "dirty".
- const pre = await git(["status", "--porcelain", "--", ...GIT_SCOPE]);
- const dirty = pre.tail
- .split("\n")
- .map((l) => l.slice(3).trim()) // strip the "XY " status prefix
- .filter(Boolean)
- .filter((p) => !p.startsWith("app/public/media/"));
- if (dirty.length) {
- return { ok: false, error: "Working tree wasn't clean — commit or discard pending changes before running the queue." };
- }
-
- return new Promise((resolve) => {
- const args = [
- "-p", prompt,
- "--output-format", "json",
- "--allowedTools", "Read Write Edit Glob Grep WebSearch Skill",
- "--model", DEFAULT_MODEL,
- ];
- const child = spawn(CLAUDE, args, { cwd: REPO, env: agentEnv() });
- let out = "";
- child.stdout.on("data", (d) => (out += d));
- child.on("close", async () => {
- let summary = "";
- try {
- const env = JSON.parse(out);
- if (typeof env.result === "string") summary = env.result.trim().slice(0, 500);
- } catch {}
-
- // Any source changes to publish?
- const status = await git(["status", "--porcelain", "--", ...GIT_SCOPE]);
- const changed = status.tail.trim() !== "";
- if (!changed) {
- return resolve({ ok: true, commit: "", summary: summary || "No changes were needed.", live: liveUrl(task.target_page) });
- }
-
- // The agent logs to the changelog by editing api/cli/seed-changelog.php
- // (it has no shell). If it did, reseed cja_changelog so the build picks up
- // the new entry — the changelog page reads the DB at build time.
- if (/seed-changelog\.php/.test(status.tail)) {
- await phpCli(["api/cli/seed-changelog.php"]);
- }
-
- // Build must pass before anything is committed.
- const built = await build({});
- if (!built.ok) {
- await git(["checkout", "--", ...GIT_SCOPE]);
- await git(["clean", "-fd", ...GIT_SCOPE]); // drop any new files the agent added
- return resolve({ ok: false, error: "The change broke the build, so it was reverted." });
- }
- await git(["add", "--", ...GIT_SCOPE]);
- const title = (task.title || "task").toString().slice(0, 120);
- await git(["commit", "-q", "-m", `web designer: ${title}\n\n[published via task queue]`]);
- const head = await git(["rev-parse", "--short", "HEAD"]);
- resolve({ ok: true, commit: head.tail.trim(), summary, live: liveUrl(task.target_page) });
- });
- child.on("error", () => resolve({ ok: false, error: "Could not start the agent." }));
- });
-}
-
-// Drain the queue sequentially. Shares the global `busy` lock so it never runs
-// concurrently with the in-page console. Re-entrant-safe via `draining`.
-async function drainTasks() {
- if (draining) return;
- draining = true;
- try {
- for (;;) {
- if (busy) break; // in-page console is mid-edit; a later kick resumes the queue
- const task = await claimNextTask();
- if (!task) break;
- busy = true;
- let res;
- try { res = await runDesignTask(task); }
- catch (e) { res = { ok: false, error: String((e && e.message) || e) }; }
- busy = false;
- await finishTask(
- task.task_id,
- res.ok ? "published" : "failed",
- res.ok ? { commit: res.commit, summary: res.summary, live: res.live } : { error: res.error || "failed" },
- );
- }
- } finally {
- draining = false;
- }
-}
-
-// ---- QA agent ----------------------------------------------------------------
-// A deterministic HTTP crawler tests the live static site (links, images, forms,
-// API, SEO/meta), stores findings via the qa-*.php CLI, and a thin LLM step
-// writes a plain-English summary. Read-only against the site — it never changes
-// anything; fixes only happen when the admin clicks "Create fix task".
-const QA_BASE = "https://carlosarias.co";
-const QA_UA = "Mozilla/5.0 (compatible; CarlosAriasQA/1.0; +https://carlosarias.co)";
-const QA_STATIC_ROUTES = [
- "/", "/about", "/services", "/services/website-design",
- "/projects", "/blog", "/contact", "/changelog", "/resume", "/faq",
-];
-let qaRunning = false;
-
-async function probe(url, { method = "GET", readBody = false, timeout = 12000, headers = {}, body = null } = {}) {
- const ctrl = new AbortController();
- const t = setTimeout(() => ctrl.abort(), timeout);
- try {
- const r = await fetch(url, {
- method, redirect: "follow", signal: ctrl.signal,
- headers: { "user-agent": QA_UA, ...headers }, body,
- });
- let text = null;
- if (readBody) text = await r.text();
- else { try { await r.body?.cancel(); } catch {} }
- return { status: r.status, ok: r.ok, finalUrl: r.url, text };
- } catch (e) {
- return { status: 0, ok: false, error: e.name === "AbortError" ? "timeout" : (e.message || "network error") };
- } finally { clearTimeout(t); }
-}
-
-async function pMap(items, concurrency, fn) {
- const out = []; let i = 0;
- const workers = Array.from({ length: Math.min(concurrency, items.length || 1) }, async () => {
- while (i < items.length) { const idx = i++; out[idx] = await fn(items[idx], idx); }
- });
- await Promise.all(workers);
- return out;
-}
-
-const qaNorm = (p) => { p = String(p).split("#")[0].split("?")[0]; if (p.length > 1) p = p.replace(/\/+$/, ""); return p || "/"; };
-const qaAbs = (href, pagePath) => { try { return new URL(href, QA_BASE + pagePath).href; } catch { return null; } };
-const qaGrabAll = (re, html) => [...String(html).matchAll(re)].map((m) => m[1]);
-const qaFirst = (re, html) => { const m = String(html).match(re); return m ? m[1].trim() : ""; };
-// Classify each
: alt missing entirely, or present-but-empty (alt="" or a
-// bare `alt`). Both are surfaced — an empty alt is only correct for a purely
-// decorative image, so a content image (photo, cover, screenshot) with empty
-// alt is a real accessibility gap, not a pass.
-function qaAltIssues(html) {
- const out = [];
- for (const m of String(html).matchAll(/
]*>/gi)) {
- const tag = m[0];
- const src = (tag.match(/\bsrc=["']([^"']+)["']/i) || [])[1];
- if (!src) continue;
- const withVal = tag.match(/\salt\s*=\s*["']([^"']*)["']/i);
- if (withVal) { if (withVal[1].trim() === "") out.push({ src, kind: "empty" }); }
- else if (/\salt(\s|>|\/)/i.test(tag)) out.push({ src, kind: "empty" }); // bare `alt`
- else out.push({ src, kind: "missing" });
- }
- return out;
-}
-
-async function runQa() {
- const findings = [];
- const add = (check_type, severity, url, detail, fix_hint = null) =>
- findings.push({ check_type, severity, url, detail, fix_hint });
-
- // ---- route set: static + DB-driven slugs -------------------------------
- let dynamic = [];
- try { dynamic = JSON.parse((await phpCli(["api/cli/qa-routes.php"])).out || "[]"); } catch {}
- const routes = [...new Set([...QA_STATIC_ROUTES, ...dynamic].map(qaNorm))];
-
- // ---- fetch every page, parse HTML --------------------------------------
- const pages = await pMap(routes, 6, async (path) => ({ path, r: await probe(QA_BASE + path, { readBody: true }) }));
-
- const fetched = new Map(); // normPath -> ok
- const titles = new Map(); // title -> [paths]
- const internal = new Set(); // internal target URLs
- const images = new Set(); // image URLs
- const external = new Map(); // external URL -> page it was found on
-
- for (const { path, r } of pages) {
- fetched.set(path, r.ok);
- if (!r.ok) {
- add("page", "error", path, `Returns ${r.status || r.error} instead of 200.`,
- `${path} returns ${r.status || r.error} instead of 200. Investigate why the page fails to render and fix it.`);
- continue;
- }
- const html = r.text || "";
- const title = qaFirst(/]*>([^<]*)<\/title>/i, html);
- const desc = qaFirst(/]+name=["']description["'][^>]+content=["']([^"']*)["']/i, html);
- const canon = qaFirst(/]+rel=["']canonical["'][^>]+href=["']([^"']*)["']/i, html);
- if (!title) add("seo", "warning", path, "Missing .", `${path} has no . Add a page-specific title via its BaseLayout props.`);
- else { if (!titles.has(title)) titles.set(title, []); titles.get(title).push(path); }
- if (!desc) add("seo", "warning", path, "Missing meta description.", `${path} has no meta description. Add a page-specific description via its BaseLayout props.`);
- if (canon) { try { const h = new URL(canon).host; if (h && h !== "carlosarias.co") add("seo", "error", path, `Canonical points to ${h}.`, `${path} canonical points to ${h} instead of carlosarias.co. Fix the site URL / canonical.`); } catch {} }
- if (!/]+lang=/i.test(html)) add("a11y", "warning", path, " has no lang attribute.", `${path} tag has no lang attribute. Add lang="en".`);
-
- for (const href of qaGrabAll(/]*\bhref=["']([^"']+)["']/gi, html)) {
- if (/^(mailto:|tel:|javascript:|#|data:)/i.test(href)) continue;
- const u = qaAbs(href, path); if (!u) continue;
- const noHash = u.split("#")[0];
- if (noHash.startsWith(QA_BASE)) internal.add(noHash);
- else if (/^https?:\/\//i.test(noHash) && !external.has(noHash)) external.set(noHash, path);
- }
- for (const src of qaGrabAll(/
]*\bsrc=["']([^"']+)["']/gi, html)) { const u = qaAbs(src, path); if (u && /^https?:/i.test(u)) images.add(u.split("#")[0]); }
- for (const a of qaAltIssues(html)) {
- if (a.kind === "empty") add("a11y", "warning", path, `Empty alt: ${a.src}`,
- `On ${path}, the image "${a.src}" has an empty alt attribute. If it conveys meaning (a photo, cover, or screenshot), add descriptive alt text that explains what it shows; leave it empty only if it is purely decorative.`);
- else add("a11y", "warning", path, `Missing alt: ${a.src}`,
- `On ${path}, the image "${a.src}" has no alt attribute. Add descriptive alt text that explains what it shows.`);
- }
- }
-
- // duplicate titles across pages
- for (const [title, paths] of titles) {
- if (paths.length > 1) add("seo", "warning", paths.join(", "), `${paths.length} pages share the title "${title}".`,
- `These pages share one ("${title}"): ${paths.join(", ")}. Give each a distinct, page-specific title.`);
- }
-
- // ---- internal links not already fetched --------------------------------
- const internalPaths = [...new Set([...internal].map((u) => qaNorm(u.replace(QA_BASE, "") || "/")))];
- const toCheck = internalPaths.filter((p) => !fetched.has(p));
- await pMap(toCheck, 8, async (p) => {
- const r = await probe(QA_BASE + p, {});
- if (!r.ok) add("link", "error", p, `Broken internal link (${r.status || r.error}).`,
- `An internal link points to ${p}, which returns ${r.status || r.error}. Find that link in the page source and fix the URL or remove the link.`);
- });
-
- // ---- images ------------------------------------------------------------
- await pMap([...images], 8, async (u) => {
- const r = await probe(u, {});
- if (!r.ok) add("image", "error", u, `Image returns ${r.status || r.error}.`,
- `The image ${u} returns ${r.status || r.error}. Fix the image path or replace the image.`);
- });
-
- // ---- external links (tolerant: only clear 404/410/DNS failures) ---------
- await pMap([...external.keys()], 6, async (u) => {
- const r = await probe(u, { method: "GET", timeout: 12000 });
- const clearlyBad = r.status === 404 || r.status === 410 || (r.status === 0 && r.error && r.error !== "timeout");
- if (clearlyBad) add("external", "warning", u, `External link may be broken (${r.status || r.error}); found on ${external.get(u)}.`,
- `The external link ${u} (on ${external.get(u)}) appears broken (${r.status || r.error}). Verify it and update or remove it.`);
- });
-
- // ---- contact form: two non-polluting probes ----------------------------
- const cHeaders = { "content-type": "application/json", origin: QA_BASE, referer: QA_BASE + "/contact" };
- const hp = await probe(QA_BASE + "/api/contact/submit", {
- method: "POST", readBody: true, headers: cHeaders,
- body: JSON.stringify({ name: "QA Bot", email: "qa@carlosarias.co", subject: "other", message: "QA honeypot probe — please ignore.", company: "qa-honeypot" }),
- });
- if (hp.status !== 200) add("form", "error", "/api/contact/submit", `Contact honeypot probe returned ${hp.status || hp.error} (expected 200).`,
- `POST /api/contact/submit returned ${hp.status || hp.error} instead of 200 for a probe. The contact form endpoint may be broken — check api/public/controllers/contact.php.`);
- const val = await probe(QA_BASE + "/api/contact/submit", {
- method: "POST", readBody: true, headers: cHeaders,
- body: JSON.stringify({ name: "QA", email: "qa@carlosarias.co", subject: "other", message: "hi" }),
- });
- if (val.status !== 422) add("form", "warning", "/api/contact/submit", `Validation probe returned ${val.status || val.error} (expected 422 for a too-short message).`,
- `POST /api/contact/submit did not reject an invalid submission (got ${val.status || val.error}, expected 422). Server-side validation may be off.`);
-
- // ---- API health --------------------------------------------------------
- const health = await probe(QA_BASE + "/api/health", { readBody: true });
- // The API wraps responses in a {ok, data, error} envelope, so db is at data.db.
- let dbOk = false; try { const j = JSON.parse(health.text || "{}"); dbOk = (j.data?.db ?? j.db) === "connected"; } catch {}
- if (health.status !== 200 || !dbOk) add("health", "error", "/api/health", `Status ${health.status || health.error}, db ${dbOk ? "connected" : "not connected"}.`,
- `/api/health returned ${health.status || health.error}${dbOk ? "" : " and the database is not connected"}. The API or database may be down.`);
-
- // ---- sitemap coverage --------------------------------------------------
- const sm = await probe(QA_BASE + "/sitemap.xml", { readBody: true });
- if (sm.ok) {
- const locs = new Set(qaGrabAll(/([^<]+)<\/loc>/gi, sm.text || "").map((l) => qaNorm(l.replace(QA_BASE, ""))));
- for (const p of routes) if (!locs.has(p)) add("sitemap", "warning", p, "Not listed in sitemap.xml.",
- `${p} is not in sitemap.xml. Add it in app/src/pages/sitemap.xml.js so search engines can find it.`);
- } else {
- add("sitemap", "warning", "/sitemap.xml", `sitemap.xml returned ${sm.status || sm.error}.`, `/sitemap.xml is unreachable (${sm.status || sm.error}). Check app/src/pages/sitemap.xml.js.`);
- }
-
- const counts = { error: 0, warning: 0, info: 0, pages: pages.length, links: toCheck.length, images: images.size, external: external.size };
- for (const f of findings) counts[f.severity] = (counts[f.severity] || 0) + 1;
- return { findings, counts };
-}
-
-// Thin LLM step: a plain-English run summary via the qa skill (best-effort).
-function qaTriage(findings, counts) {
- const top = [
- ...findings.filter((f) => f.severity === "error").slice(0, 12),
- ...findings.filter((f) => f.severity === "warning").slice(0, 12),
- ];
- const lines = top.map((f) => `- [${f.severity}] ${f.check_type} ${f.url}: ${f.detail}`).join("\n") || "(no issues found)";
- const fallback = `${counts.error} error(s) and ${counts.warning} warning(s) across ${counts.pages} pages.`;
- const ask = [
- "You are the QA agent for carlosarias.co. A crawler just tested the live site. Consult your `qa` skill.",
- `Counts: ${counts.error} errors, ${counts.warning} warnings across ${counts.pages} pages.`,
- "Top findings:", lines,
- "",
- "Write a 2-4 sentence plain-English summary for the site owner: overall health, the most important things to fix first, and whether anything is urgent. No preamble — just the summary.",
- ].join("\n");
- return new Promise((resolve) => {
- const child = spawn(CLAUDE, ["-p", ask, "--output-format", "json", "--allowedTools", "Read Skill", "--model", DEFAULT_MODEL], { cwd: REPO, env: agentEnv() });
- let out = "";
- child.stdout.on("data", (d) => (out += d));
- child.on("close", () => {
- let s = "";
- try { const e = JSON.parse(out); if (typeof e.result === "string") s = e.result.trim().slice(0, 800); } catch {}
- resolve(s || fallback);
- });
- child.on("error", () => resolve(fallback));
- });
-}
-
-async function runQaFlow(trigger) {
- if (qaRunning) return;
- qaRunning = true;
- let runId = 0;
- try {
- const start = await phpCli(["api/cli/qa-start.php", `--trigger=${trigger}`]);
- runId = JSON.parse(start.out || "{}").run_id || 0;
- if (!runId) throw new Error("could not open a QA run");
- const { findings, counts } = await runQa();
- // Triage (an LLM call) only when it's worth it: a manual run, or a
- // scheduled heartbeat that actually found errors. Clean heartbeats get a
- // cheap templated summary — no tokens spent when the site is fine.
- const summary = (trigger === "manual" || counts.error > 0)
- ? await qaTriage(findings, counts)
- : `${counts.error} error(s) and ${counts.warning} warning(s) across ${counts.pages} pages.`;
- const tmp = `/tmp/qa-${runId}.json`;
- writeFileSync(tmp, JSON.stringify(findings));
- await phpCli(["api/cli/qa-finish.php", `--run=${runId}`, "--status=done", `--summary=${summary}`, `--counts=${JSON.stringify(counts)}`, `--findings-file=${tmp}`]);
- rmSync(tmp, { force: true });
-
- // Auto-fix the safe findings (alt text, broken internal links) — queue Web
- // Designer tasks and kick the queue. Judgment calls are left for the admin.
- if (QA_AUTOFIX) {
- const af = await phpCli(["api/cli/qa-autofix.php", `--run=${runId}`]);
- let queued = 0; try { queued = JSON.parse(af.out || "{}").queued || 0; } catch {}
- if (queued > 0) drainTasks(); // fire-and-forget; the Web Designer fixes + self-logs
- }
- } catch (e) {
- if (runId) await phpCli(["api/cli/qa-finish.php", `--run=${runId}`, "--status=failed", `--summary=QA run failed: ${String((e && e.message) || e).slice(0, 180)}`]);
- } finally {
- qaRunning = false;
- }
-}
-
-// ---- request helpers ---------------------------------------------------------
-function json(res, status, obj) {
- const body = JSON.stringify(obj);
- res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
- res.end(body);
-}
-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({}); }
- });
- });
-}
-
-// ---- server ------------------------------------------------------------------
-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 {
- if (path === "/ping") {
- const env = loadEnv();
- return json(res, 200, { authed: true, hasToken: Boolean(env.CLAUDE_CODE_OAUTH_TOKEN) });
- }
-
- if (path === "/auth" && method === "POST") {
- 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());
- return json(res, 200, { ok: true });
- }
-
- if (path === "/logout" && method === "POST") {
- return json(res, 200, { ok: true }); // admin session is cleared by PHP
- }
-
- if (path === "/run" && method === "POST") {
- if (busy) return json(res, 429, { error: "A change is already in progress — let it finish." });
- const body = await readBody(req);
- const message = String(body.message || "").trim();
- if (!message) return json(res, 400, { error: "Say what you'd like changed." });
-
- const env = loadEnv();
- if (!env.CLAUDE_CODE_OAUTH_TOKEN) {
- return json(res, 401, { error: "No Claude token set. Add one with the 🔑 button." });
- }
-
- // Continue an existing conversation, or start a new one.
- let job = body.conversationId && jobs.get(body.conversationId);
- if (job) { job.done = false; job.events = []; job.jobId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`; }
- else job = newJob(body.page);
-
- busy = true;
- runAgent(job, { message, page: body.page, selections: body.selections, model: body.model });
- return json(res, 200, { conversationId: job.conversationId });
- }
-
- // Draft project copy from a rough note + prompt (used by the New Project form).
- if (path === "/draft" && method === "POST") {
- if (busy) return json(res, 429, { error: "Busy — try again in a moment." });
- const body = await readBody(req);
- const name = String(body.name || "").trim();
- if (!name) return json(res, 400, { error: "Add a project name first." });
- const env = loadEnv();
- if (!env.CLAUDE_CODE_OAUTH_TOKEN) return json(res, 401, { error: "No Claude token set." });
-
- busy = true;
- const d = await draftCopy(body);
- busy = false;
- if (!d.ok) return json(res, 500, { error: "Couldn't generate a draft — try again." });
- return json(res, 200, d);
- }
-
- // Web Designer: build a full structured project from a brief + images.
- if (path === "/build-project" && method === "POST") {
- if (busy) return json(res, 429, { error: "Busy — try again in a moment." });
- const body = await readBody(req);
- const name = String(body.name || "").trim();
- if (!name) return json(res, 400, { error: "Add a project name first." });
- const env = loadEnv();
- if (!env.CLAUDE_CODE_OAUTH_TOKEN) return json(res, 401, { error: "No Claude token set." });
-
- busy = true;
- const d = await buildProject(body);
- busy = false;
- if (!d.ok) return json(res, 500, { error: "The Web Designer couldn't finish — try again." });
- return json(res, 200, d);
- }
-
- // Kick the Web Designer task queue: drain any queued design tasks. Returns
- // immediately; the admin UI polls /api/admintasks/list for status.
- if (path === "/tasks/run" && method === "POST") {
- const env = loadEnv();
- if (!env.CLAUDE_CODE_OAUTH_TOKEN) return json(res, 401, { error: "No Claude token set." });
- drainTasks(); // fire-and-forget
- return json(res, 200, { ok: true, draining, busy });
- }
-
- // Kick a QA run: crawl the live site, store findings. Read-only; returns
- // immediately. The admin polls /api/adminqa/runs for status.
- if (path === "/qa/run" && method === "POST") {
- if (qaRunning) return json(res, 429, { error: "A QA run is already in progress." });
- const body = await readBody(req);
- const trigger = body.trigger === "scheduled" ? "scheduled" : "manual";
- runQaFlow(trigger); // fire-and-forget
- return json(res, 200, { ok: true });
- }
-
- if (path === "/stream") {
- const id = url.searchParams.get("conversationId");
- const job = id && jobs.get(id);
- if (!job) return json(res, 404, { error: "unknown conversation" });
- res.writeHead(200, {
- "content-type": "text/event-stream",
- "cache-control": "no-cache",
- connection: "keep-alive",
- });
- // replay buffered, then stream live
- for (const ev of job.events) res.write(`data: ${JSON.stringify(ev)}\n\n`);
- if (job.done) return res.end();
- job.clients.add(res);
- req.on("close", () => job.clients.delete(res));
- return;
- }
-
- if (path === "/publish" && method === "POST") {
- if (busy) return json(res, 429, { error: "Busy — try again in a moment." });
- const { conversationId } = await readBody(req);
- const job = conversationId && jobs.get(conversationId);
- if (!job) return json(res, 404, { error: "Nothing to publish." });
-
- busy = true;
- const built = await build({}); // real build to public/
- if (!built.ok) { busy = false; return json(res, 500, { error: "Build failed — not published." }); }
- await git(["add", "--", "app", "brand", "api/db", "api/cli"]);
- const summary = (job.page && job.page !== "/" ? job.page : "homepage");
- await git(["commit", "-q", "-m", `console: edit ${summary}\n\n[published via astroagent console]`]);
- rmSync(join(PREVIEW_DIR, job.jobId), { recursive: true, force: true });
- busy = false;
- return json(res, 200, { ok: true, live: job.page || "/" });
- }
-
- // Rebuild the live site — used after structured content edits (projects
- // gallery, etc.) that changed the DB rather than files. Builds public/ and
- // commits any new media files (a no-op commit is fine).
- if (path === "/rebuild" && method === "POST") {
- if (busy) return json(res, 429, { error: "Busy — try again in a moment." });
- busy = true;
- const built = await build({});
- if (!built.ok) { busy = false; return json(res, 500, { error: "Build failed." }); }
- await git(["add", "--", "app/public/media", "app", "brand", "api/db", "api/cli"]);
- await git(["commit", "-q", "-m", "console: content update"]); // ok if nothing to commit
- busy = false;
- return json(res, 200, { ok: true });
- }
-
- if (path === "/discard" && method === "POST") {
- if (busy) return json(res, 429, { error: "Busy — try again in a moment." });
- const { conversationId } = await readBody(req);
- const job = conversationId && jobs.get(conversationId);
- busy = true;
- const CONTENT = ["app", "brand", "api/db", "api/cli"];
- await git(["checkout", "--", ...CONTENT]);
- await git(["clean", "-fd", ...CONTENT]);
- if (job) rmSync(join(PREVIEW_DIR, job.jobId), { recursive: true, force: true });
- busy = false;
- if (job) jobs.delete(job.conversationId);
- return json(res, 200, { ok: true });
- }
-
- return json(res, 404, { error: "not found" });
- } catch (err) {
- return 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}`);
-});
-
-// ---- QA heartbeat ------------------------------------------------------------
-// The runner is long-lived, so the QA heartbeat is just an in-process timer —
-// no cron, no extra units. Runs a scheduled QA pass every QA_HEARTBEAT_MIN
-// minutes (0 disables), plus one pulse shortly after boot. runQaFlow self-guards
-// against overlap and is read-only against the live site.
-const QA_AUTOFIX = (process.env.QA_AUTOFIX ?? "1") !== "0"; // auto-fix safe findings
-const QA_HEARTBEAT_MIN = Number(process.env.QA_HEARTBEAT_MIN || 60);
-if (QA_HEARTBEAT_MIN > 0) {
- setTimeout(() => runQaFlow("scheduled"), 90_000); // first pulse ~90s after boot
- setInterval(() => runQaFlow("scheduled"), QA_HEARTBEAT_MIN * 60_000);
- console.log(`[qa] heartbeat every ${QA_HEARTBEAT_MIN} min`);
-}
+import { host } from "./host.mjs";
+import { register as registerInPageConsole } from "./agents/in-page-console.mjs";
+import { register as registerProjectBuilder } from "./agents/project-builder.mjs";
+import { register as registerWebDesigner } from "./agents/web-designer.mjs";
+import { register as registerQa } from "./agents/qa.mjs";
+
+registerInPageConsole(host);
+registerProjectBuilder(host);
+registerWebDesigner(host);
+registerQa(host);
+
+host.listen();