#!/usr/bin/env node

import * as http from "http";
import * as https from "https";
import * as child_process from "child_process";
import * as fs from "fs";
import * as path from "path";
import * as os from "os";
import * as crypto from "crypto";
import { pipeline, env } from "@xenova/transformers";
import * as cron from "node-cron";

// Browser automation is loaded lazily — the agent must start instantly even
// when playwright's browser binaries were never installed.
let browserModule: any = null;
let browserInstance: any = null;
/** Open pages (tabs). Multi-page so the expert can compare sources side by side. */
let browserPages: any[] = [];
/** Index into browserPages for the page the next browser_* call acts on. */
let browserActive = 0;
/** One shared context (cookies/storage) across all pages; null until first use. */
let browserContext: any = null;

/** Only one install may run at a time, however many tasks arrive together. */
let chromiumInstall: Promise<boolean> | null = null;

/**
 * Download the headless browser ourselves.
 *
 * The agent already self-updates through npm, so telling a customer to run
 * `npx playwright install` by hand was busywork we could just do. playwright-core
 * ships the same installer the `playwright` package exposes, so we invoke it
 * directly. `--only-shell` fetches the headless shell (~50MB rather than a full
 * browser), which is all a headless task needs.
 */
function installChromium(): Promise<boolean> {
  if (chromiumInstall) return chromiumInstall;
  chromiumInstall = new Promise<boolean>((resolve) => {
    const cli = path.join(__dirname, "..", "node_modules", "playwright-core", "cli.js");
    if (!fs.existsSync(cli)) return resolve(false);
    console.log("=> Preparing the browser (one-time download, ~50MB)…");
    const proc = child_process.spawn(process.execPath, [cli, "install", "chromium", "--only-shell", "--no-progress"], {
      stdio: ["ignore", "pipe", "pipe"],
      // Keep the download inside the agent's own directory so it survives
      // npm updates and doesn't depend on a writable global cache.
      env: { ...process.env, PLAYWRIGHT_BROWSERS_PATH: path.join(os.homedir(), ".aclade-browsers") },
    });
    let err = "";
    proc.stdout.on("data", (d) => process.stdout.write(`   ${d}`));
    proc.stderr.on("data", (d) => (err += d.toString()));
    // Generous: a slow connection shouldn't leave a half-installed browser.
    const timer = setTimeout(() => proc.kill("SIGKILL"), 10 * 60_000);
    proc.on("error", () => { clearTimeout(timer); resolve(false); });
    proc.on("close", (code) => {
      clearTimeout(timer);
      if (code === 0) console.log("=> Browser ready.");
      else console.error(`=> Browser download failed (exit ${code}): ${err.slice(0, 300)}`);
      resolve(code === 0);
    });
  }).finally(() => {
    // Allow a later retry if it failed; a success needs no repeat.
    setTimeout(() => { chromiumInstall = null; }, 60_000);
  }) as Promise<boolean>;
  return chromiumInstall;
}

/** Launch the browser if needed, reusing one browser + one context for all pages. */
async function ensureBrowser(): Promise<{ context: any }> {
  if (!browserModule) {
    try {
      browserModule = require("playwright-core");
    } catch {
      throw new Error("browser support is unavailable — reinstall the agent: npm install -g aclade-agent@latest");
    }
  }
  if (!browserInstance) {
    // Prefer a browser the machine already has: the customer's own Chrome or
    // Edge inherits their proxy settings, certificate store and policies, which
    // is usually what corporate work needs — and it needs no download at all.
    const launch = async (channel?: string) =>
      browserModule.chromium.launch({ headless: true, ...(channel ? { channel } : {}) });

    const attempts: (string | undefined)[] = ["chrome", "msedge", undefined];
    let lastError: Error | null = null;
    for (const channel of attempts) {
      try {
        browserInstance = await launch(channel);
        break;
      } catch (e) {
        lastError = e as Error;
      }
    }

    // Nothing usable on this machine — fetch the headless shell and retry once.
    if (!browserInstance) {
      const installed = await installChromium();
      if (!installed) {
        throw new Error(
          `no usable browser on this machine and the automatic download failed. ` +
            `Install Chrome, or run: npx playwright-core install chromium --only-shell. (${lastError?.message ?? "unknown"})`
        );
      }
      browserInstance = await launch();
    }
  }
  if (!browserContext) {
    browserContext = await browserInstance.newContext({
      viewport: { width: 1440, height: 900 },
      userAgent:
        "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0 Safari/537.36",
    });
  }
  return { context: browserContext };
}

/** Open a NEW page (tab) in the shared browser and make it active. */
async function newPage(): Promise<any> {
  const { context } = await ensureBrowser();
  const page = await context.newPage();
  browserPages.push(page);
  browserActive = browserPages.length - 1;
  return page;
}

/**
 * The page the next browser_* call acts on: the last opened one if any, else a
 * fresh page. Stale (closed) pages are pruned so the index stays valid.
 */
async function getActivePage(): Promise<any> {
  if (browserPages.length) {
    browserPages = browserPages.filter((p) => !p.isClosed());
    if (browserActive >= browserPages.length) browserActive = browserPages.length - 1;
    if (browserPages[browserActive]) return browserPages[browserActive];
    // All pages closed — fall through to open a new one.
  }
  return newPage();
}

async function closeBrowser() {
  try {
    // Closing the browser tears down every page and context with it.
    if (browserInstance) await browserInstance.close();
  } catch {
    /* already gone */
  }
  browserInstance = null;
  browserContext = null;
  browserPages = [];
  browserActive = 0;
}

/** Readable text from the current page, with script/style stripped. */
async function pageText(page: any, limit = 20000): Promise<string> {
  const text: string = await page.evaluate(`(() => {
    const clone = document.body.cloneNode(true);
    for (const el of clone.querySelectorAll('script,style,noscript,svg')) el.remove();
    return (clone.innerText || '').replace(/\\n{3,}/g, '\\n\\n').trim();
  })()`);
  return text.length > limit ? text.slice(0, limit) + "\n…[truncated]" : text;
}

env.allowLocalModels = true;

// Browsers we download ourselves live here, so they survive npm updates and
// don't need a writable global cache. Set before playwright-core is required.
process.env.PLAYWRIGHT_BROWSERS_PATH =
  process.env.PLAYWRIGHT_BROWSERS_PATH || path.join(os.homedir(), ".aclade-browsers");

const pkgPath = path.join(__dirname, "..", "package.json");
const CURRENT_VERSION = fs.existsSync(pkgPath)
  ? JSON.parse(fs.readFileSync(pkgPath, "utf-8")).version
  : "0.0.0";

const CONFIG_PATH = path.join(os.homedir(), ".aclade-agent.json");
/** Owned by the running daemon; the `aclade` CLI uses it for status/stop. */
const PID_PATH = path.join(os.homedir(), ".aclade-agent.pid");

/** How many tasks may execute at once. Tool calls are usually independent. */
const MAX_CONCURRENT_TASKS = 4;
/** Hard ceiling on a single command. The server gives up at 60s. */
const COMMAND_TIMEOUT_MS = 55_000;
const MAX_OUTPUT_BYTES = 2 * 1024 * 1024;

interface AgentConfig {
  token: string;
  host: string;
  /** Stable per-installation identity, used for fleet telemetry. */
  installId: string;
  /** Optional root the agent refuses to operate outside of. */
  workspaceRoot?: string;
}

function loadOrCreateConfig(): AgentConfig {
  const args = process.argv.slice(2);
  let token = "";
  let host = "https://aclade.com";
  let workspaceRoot: string | undefined;

  for (let i = 0; i < args.length; i++) {
    if (args[i] === "--token" && args[i + 1]) token = args[++i];
    else if (args[i] === "--host" && args[i + 1]) host = args[++i];
    else if (args[i] === "--workspace" && args[i + 1]) workspaceRoot = path.resolve(args[++i]);
  }

  let stored: Partial<AgentConfig> = {};
  if (fs.existsSync(CONFIG_PATH)) {
    try {
      stored = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8"));
    } catch {
      /* corrupt config — start fresh */
    }
  }

  token = token || stored.token || "";
  if (!workspaceRoot && stored.workspaceRoot) workspaceRoot = stored.workspaceRoot;
  if (args.indexOf("--host") === -1 && stored.host) host = stored.host;

  // The install id identifies this machine to the fleet dashboard. It is
  // generated locally and is not derived from the token, so rotating a token
  // doesn't look like a brand-new installation.
  const installId = stored.installId || crypto.randomBytes(12).toString("hex");

  const config: AgentConfig = { token, host, installId, workspaceRoot };
  if (token) {
    try {
      // 0600: the token is a credential for this workspace.
      fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), { mode: 0o600 });
      fs.chmodSync(CONFIG_PATH, 0o600);
    } catch {
      /* best-effort */
    }
  }
  return config;
}

function makeRequest(urlStr: string, options: http.RequestOptions, bodyData?: string): Promise<string> {
  return new Promise((resolve, reject) => {
    const url = new URL(urlStr);
    const client = url.protocol === "https:" ? https : http;
    const req = client.request(url, { timeout: 30_000, ...options }, (res) => {
      let data = "";
      res.on("data", (chunk) => (data += chunk));
      res.on("end", () => {
        if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) resolve(data);
        else reject(Object.assign(new Error(`HTTP ${res.statusCode}: ${data.slice(0, 300)}`), { status: res.statusCode }));
      });
    });
    req.on("error", reject);
    req.on("timeout", () => { req.destroy(); reject(new Error("request timed out")); });
    if (bodyData) req.write(bodyData);
    req.end();
  });
}

// ---- Local semantic index (RAG on the customer's own machine) ---------------
// Documents are embedded and searched locally; raw file contents never leave
// the network unless a tool result explicitly returns them.

let embedder: any = null;
async function getEmbedder() {
  if (!embedder) {
    console.log("=> Initialising local embedding engine (first run downloads the model)…");
    embedder = await pipeline("feature-extraction", "Xenova/all-MiniLM-L6-v2");
    console.log("=> Embedding engine ready.");
  }
  return embedder;
}

interface VectorEntry { path: string; text: string; vector: number[] }
const vectorStore: VectorEntry[] = [];

function cosine(a: number[], b: number[]): number {
  let dot = 0;
  for (let i = 0; i < a.length; i++) dot += a[i] * b[i];
  return dot; // vectors are L2-normalised by the extractor
}

async function embed(text: string): Promise<number[]> {
  const ext = await getEmbedder();
  const out = await ext(text, { pooling: "mean", normalize: true });
  return Array.from(out.data as Float32Array);
}

// ---- Path safety ------------------------------------------------------------

let workspaceRoot: string | undefined;

/**
 * Resolve a path and, when a workspace root is configured, refuse anything
 * outside it. Without a root the agent behaves as before (full access), which
 * is what an operator who ran it unscoped asked for.
 */
function resolvePath(p: string): string {
  const resolved = path.resolve(p);
  if (workspaceRoot) {
    const rel = path.relative(workspaceRoot, resolved);
    if (rel.startsWith("..") || path.isAbsolute(rel)) {
      throw new Error(`path is outside the permitted workspace (${workspaceRoot})`);
    }
  }
  return resolved;
}

/**
 * Run a binary with an ARGUMENT ARRAY and no shell.
 *
 * The previous implementation interpolated tool input into `execSync` strings —
 * `grep -r "${term}" "${path}"` — so a search term containing $(...) or
 * backticks executed arbitrary commands. Passing argv directly removes the
 * shell from the path entirely.
 */
function runBinary(cmd: string, args: string[], opts: { cwd?: string } = {}): Promise<{ output?: string; error?: string; exitCode: number }> {
  return new Promise((resolve) => {
    // nosemgrep: javascript.lang.security.detect-child-process.detect-child-process
    // argv array, no shell — injection-safe by construction.
    const proc = child_process.spawn(cmd, args, { shell: false, cwd: opts.cwd, stdio: ["ignore", "pipe", "pipe"] }); // nosemgrep: javascript.lang.security.detect-child-process.detect-child-process
    let stdout = "";
    let stderr = "";
    let truncated = false;
    const timer = setTimeout(() => { proc.kill("SIGKILL"); }, COMMAND_TIMEOUT_MS);

    proc.stdout.on("data", (c) => { if (stdout.length < MAX_OUTPUT_BYTES) stdout += c.toString(); else truncated = true; });
    proc.stderr.on("data", (c) => { if (stderr.length < MAX_OUTPUT_BYTES) stderr += c.toString(); else truncated = true; });
    proc.on("error", (err) => { clearTimeout(timer); resolve({ error: err.message, exitCode: -1 }); });
    proc.on("close", (code) => {
      clearTimeout(timer);
      if (truncated) stdout += "\n…[output truncated at 2MB]…";
      resolve({ output: stdout, error: stderr || undefined, exitCode: code ?? -1 });
    });
  });
}

/** Walk a directory tree in-process, skipping the usual noise. */
const IGNORED_DIRS = new Set([".git", "node_modules", ".next", "dist", "build", "__pycache__", ".venv", "venv", ".cache", "target", "vendor"]);

function walk(dir: string, out: string[] = [], budget = { files: 20_000 }): string[] {
  if (budget.files <= 0) return out;
  let entries: fs.Dirent[];
  try {
    entries = fs.readdirSync(dir, { withFileTypes: true });
  } catch {
    return out;
  }
  for (const e of entries) {
    if (budget.files <= 0) break;
    const full = path.join(dir, e.name);
    if (e.isDirectory()) {
      if (IGNORED_DIRS.has(e.name) || e.name.startsWith(".")) continue;
      walk(full, out, budget);
    } else if (e.isFile()) {
      out.push(full);
      budget.files--;
    }
  }
  return out;
}

/** Minimal glob: supports **, * and ? — enough for the patterns models produce. */
function globToRegExp(pattern: string): RegExp {
  const escaped = pattern
    .replace(/[.+^${}()|[\]\\]/g, "\\$&")
    .replace(/\*\*/g, " ")
    .replace(/\*/g, "[^/]*")
    .replace(/ /g, ".*")
    .replace(/\?/g, ".");
  return new RegExp(`^${escaped}$`);
}

async function executeTask(task: any): Promise<any> {
  const { toolName, input } = task;

  try {
    switch (toolName) {
      case "execute_bash": {
        // Deliberately a shell: this tool exists to run the operator's own
        // commands. Bounded by timeout and output cap.
        return await new Promise((resolve) => {
          // nosemgrep: javascript.lang.security.detect-child-process.detect-child-process
          // Deliberate shell: execute_bash runs the tenant's own commands INSIDE
          // the isolated sandbox container (ICC-off network + egress firewall +
          // resource caps, see scripts/sandbox-broker.mjs). It never runs on the
          // host — the agent package only executes in-sandbox.
          const proc = child_process.spawn(input.command, [], { // nosemgrep: javascript.lang.security.detect-child-process.detect-child-process
            shell: true,
            cwd: input.cwd ? resolvePath(input.cwd) : workspaceRoot,
            stdio: ["ignore", "pipe", "pipe"],
          });
          let stdout = "";
          let stderr = "";
          let truncated = false;
          const timer = setTimeout(() => {
            proc.kill("SIGKILL");
            stderr += `\n[aclade-agent] command killed after ${COMMAND_TIMEOUT_MS / 1000}s`;
          }, COMMAND_TIMEOUT_MS);

          proc.stdout.on("data", (c) => { if (stdout.length < MAX_OUTPUT_BYTES) stdout += c.toString(); else truncated = true; });
          proc.stderr.on("data", (c) => { if (stderr.length < MAX_OUTPUT_BYTES) stderr += c.toString(); else truncated = true; });
          proc.on("error", (err) => { clearTimeout(timer); resolve({ error: err.message }); });
          proc.on("close", (code) => {
            clearTimeout(timer);
            if (truncated) stdout += "\n…[output truncated at 2MB]…";
            resolve({ output: stdout || "", error: stderr || "", exitCode: code });
          });
        });
      }

      case "read_file": {
        const p = resolvePath(input.path);
        const stat = fs.statSync(p);
        if (stat.size > MAX_OUTPUT_BYTES) {
          const fd = fs.openSync(p, "r");
          const buf = Buffer.alloc(MAX_OUTPUT_BYTES);
          fs.readSync(fd, buf, 0, MAX_OUTPUT_BYTES, 0);
          fs.closeSync(fd);
          return { output: `${buf.toString("utf-8")}\n…[truncated: file is ${stat.size} bytes]…` };
        }
        return { output: fs.readFileSync(p, "utf-8") };
      }

      case "write_file": {
        const p = resolvePath(input.path);
        fs.mkdirSync(path.dirname(p), { recursive: true });
        fs.writeFileSync(p, input.content, "utf-8");
        return { output: `Wrote ${Buffer.byteLength(input.content)} bytes to ${p}` };
      }

      case "replace_file_content": {
        const p = resolvePath(input.path);
        const content = fs.readFileSync(p, "utf-8");
        const occurrences = content.split(input.target_content).length - 1;
        if (occurrences === 0) return { error: "Target content not found in file." };
        if (occurrences > 1 && !input.replace_all) {
          return { error: `Target content appears ${occurrences} times — make it unique or set replace_all.` };
        }
        const updated = input.replace_all
          ? content.split(input.target_content).join(input.new_content)
          : content.replace(input.target_content, input.new_content);
        fs.writeFileSync(p, updated, "utf-8");
        return { output: `Replaced ${input.replace_all ? occurrences : 1} occurrence(s) in ${p}` };
      }

      case "list_directory_recursive": {
        const root = resolvePath(input.path);
        const files = walk(root);
        return { output: files.join("\n").slice(0, 500_000) };
      }

      case "glob_files": {
        const root = resolvePath(input.path || ".");
        const re = globToRegExp(input.pattern);
        const matches = walk(root)
          .filter((f) => re.test(path.relative(root, f).split(path.sep).join("/")))
          .slice(0, 2000);
        return { output: matches.length ? matches.join("\n") : "No files matched." };
      }

      case "grep_search": {
        const root = resolvePath(input.path);
        // argv array — no shell, so the search term cannot inject commands.
        const args = ["-rn", "--binary-files=without-match"];
        if (input.ignore_case) args.push("-i");
        for (const d of IGNORED_DIRS) args.push(`--exclude-dir=${d}`);
        args.push("-e", input.term, root);
        const res = await runBinary("grep", args);
        // grep exits 1 when there are no matches; that isn't an error.
        if (res.exitCode === 1 && !res.output) return { output: "No matches found." };
        if (res.exitCode > 1) return { error: res.error || `grep exited ${res.exitCode}` };
        return { output: (res.output || "").slice(0, 500_000) };
      }

      case "embed_texts": {
        const results: number[][] = [];
        for (const text of input.texts) results.push(await embed(text));
        return { output: results };
      }

      case "index_workspace": {
        const root = resolvePath(input.path || ".");
        const exts = new Set((input.extensions as string[] | undefined) ?? [".ts", ".tsx", ".js", ".py", ".md", ".txt", ".json", ".java", ".go", ".rb", ".rs"]);
        const files = walk(root).filter((f) => exts.has(path.extname(f))).slice(0, 800);
        vectorStore.length = 0;
        let indexed = 0;
        for (const file of files) {
          try {
            const text = fs.readFileSync(file, "utf-8");
            // ~60-line windows keep each vector locally coherent.
            const lines = text.split("\n");
            for (let i = 0; i < lines.length; i += 60) {
              const chunk = lines.slice(i, i + 60).join("\n").trim();
              if (chunk.length < 40) continue;
              vectorStore.push({ path: `${file}:${i + 1}`, text: chunk, vector: await embed(chunk) });
              indexed++;
              if (indexed >= 4000) break;
            }
          } catch { /* skip unreadable files */ }
          if (indexed >= 4000) break;
        }
        return { output: `Indexed ${indexed} passages from ${files.length} files. semantic_search is now available.` };
      }

      case "semantic_search": {
        if (vectorStore.length === 0) return { output: "Nothing indexed yet — run index_workspace first." };
        const qv = await embed(input.query);
        const hits = vectorStore
          .map((e) => ({ ...e, score: cosine(qv, e.vector) }))
          .sort((a, b) => b.score - a.score)
          .slice(0, Math.min(Number(input.limit) || 5, 15));
        return { output: hits.map((h) => `${h.path} (${h.score.toFixed(3)})\n${h.text}`).join("\n\n---\n\n") };
      }

      case "browser_open": {
        // newTab opens a separate page so the expert can keep the previous one.
        const page = input.newTab ? await newPage() : await getActivePage();
        await page.goto(String(input.url), { waitUntil: "domcontentloaded", timeout: 45000 });
        if (input.waitFor) await page.waitForSelector(String(input.waitFor), { timeout: 20000 }).catch(() => {});
        // Give client-rendered pages a moment to settle before reading.
        await page.waitForLoadState("networkidle", { timeout: 10000 }).catch(() => {});
        const label = browserPages.length > 1 ? ` [page ${browserActive + 1}]` : "";
        return { output: `${await page.title()}${label}\nURL: ${page.url()}\n\n${await pageText(page)}` };
      }

      case "browser_select": {
        // Switch which page subsequent browser_* calls act on. Accepts a 1-based
        // index or the keyword "new" (open a blank page) or "close" (close the
        // active page). Returns the list of open pages so the model can choose.
        const arg = String(input.page ?? "1").trim().toLowerCase();
        if (arg === "new") {
          await newPage();
        } else if (arg === "close") {
          const p = browserPages[browserActive];
          if (p && !p.isClosed()) await p.close().catch(() => {});
          browserPages = browserPages.filter((x) => !x.isClosed());
          if (browserActive >= browserPages.length) browserActive = Math.max(0, browserPages.length - 1);
        } else {
          const idx = parseInt(arg, 10) - 1;
          if (Number.isNaN(idx) || idx < 0 || idx >= browserPages.length) {
            return { error: `No such page: ${input.page}. Open pages: ${browserPages.map((_, i) => i + 1).join(", ") || "(none)"}.` };
          }
          browserActive = idx;
        }
        const list = browserPages
          .map((p, i) => `  ${i + 1}${i === browserActive ? " (active)" : ""}: ${p.url()}`)
          .join("\n");
        return { output: `Active page is now ${browserPages.length ? browserPages[browserActive].url() : "(none)"}.\nOpen pages:\n${list || "  (none)"}` };
      }

      case "browser_evaluate": {
        const page = await getActivePage();
        // Run the customer's JS in the page and return the result, serialised.
        const result = await page.evaluate(String(input.expression), { timeout: 20000 });
        let text: string;
        try {
          text = typeof result === "string" ? result : JSON.stringify(result, null, 2);
        } catch {
          text = String(result);
        }
        return { output: `URL: ${page.url()}\n${(text ?? "null").slice(0, 200_000)}` };
      }

      case "browser_click": {
        const page = await getActivePage();
        await page.click(String(input.selector), { timeout: 15000 });
        await page.waitForLoadState("networkidle", { timeout: 10000 }).catch(() => {});
        return { output: `Clicked ${input.selector}.\nURL: ${page.url()}\n\n${await pageText(page, 8000)}` };
      }

      case "browser_fill": {
        const page = await getActivePage();
        for (const [selector, value] of Object.entries(input.fields ?? {})) {
          await page.fill(String(selector), String(value), { timeout: 15000 });
        }
        if (input.submit) {
          await page.click(String(input.submit), { timeout: 15000 });
          await page.waitForLoadState("networkidle", { timeout: 15000 }).catch(() => {});
        }
        return { output: `Filled ${Object.keys(input.fields ?? {}).length} field(s).\nURL: ${page.url()}\n\n${await pageText(page, 8000)}` };
      }

      case "browser_screenshot": {
        const page = await getActivePage();
        const buf = await page.screenshot({ fullPage: !!input.fullPage, type: "png" });
        // Returned as a data URI so the expert can attach it to the conversation.
        return { output: `data:image/png;base64,${buf.toString("base64")}` };
      }

      case "browser_close": {
        await closeBrowser();
        return { output: "Browser closed." };
      }

      case "schedule_cron": {
        if (!cron.validate(input.cron_expression)) return { error: "Invalid cron expression." };
        cron.schedule(input.cron_expression, () => {
          child_process.exec(input.command, { timeout: COMMAND_TIMEOUT_MS }, (err, stdout, stderr) => { // nosemgrep: javascript.lang.security.detect-child-process.detect-child-process
            if (err) console.error("=> [cron error]", err.message);
            if (stdout) console.log("=> [cron stdout]", stdout.slice(0, 2000));
            if (stderr) console.error("=> [cron stderr]", stderr.slice(0, 2000));
          });
        });
        return { output: `Scheduled: ${input.cron_expression}` };
      }

      default:
        return { error: `Unknown tool: ${toolName}` };
    }
  } catch (e: any) {
    return { error: e.message };
  }
}

// ---- Poll loop --------------------------------------------------------------

async function checkForUpdate() {
  try {
    const body = await new Promise<string>((resolve, reject) => {
      https
        .get("https://registry.npmjs.org/aclade-agent/latest", (resp) => {
          let d = "";
          resp.on("data", (c) => (d += c));
          resp.on("end", () => resolve(d));
        })
        .on("error", reject);
    });
    const latest = JSON.parse(body).version;
    if (latest && latest !== CURRENT_VERSION) {
      console.log(`\n=> [update] ${latest} available (running ${CURRENT_VERSION}). Upgrading…`);
      child_process.execSync("npm install -g aclade-agent@latest", { stdio: "ignore" });
      console.log("=> [update] done — restarting.");
      process.exit(0);
    }
  } catch {
    /* update checks are best-effort */
  }
}

async function pollLoop(config: AgentConfig) {
  console.log(`=> Connected to ${config.host}. Waiting for work…`);
  if (workspaceRoot) console.log(`=> Scoped to workspace: ${workspaceRoot}`);

  let lastUpdateCheck = 0;
  let inFlight = 0;
  let authFailures = 0;

  const params = new URLSearchParams({
    token: config.token,
    installId: config.installId,
    version: CURRENT_VERSION,
    platform: `${os.platform()}-${os.arch()}`,
    hostname: os.hostname(),
  });

  while (true) {
    try {
      if (Date.now() - lastUpdateCheck > 60 * 60 * 1000) {
        lastUpdateCheck = Date.now();
        void checkForUpdate();
      }

      // Saturate the concurrency budget before sleeping: independent tool calls
      // (read three files, grep two trees) should not run one at a time.
      if (inFlight >= MAX_CONCURRENT_TASKS) {
        await new Promise((r) => setTimeout(r, 200));
        continue;
      }

      const res = await makeRequest(`${config.host}/api/connector/poll?${params}`, {
        method: "GET",
        headers: { Accept: "application/json", Authorization: `Bearer ${config.token}` },
      });
      authFailures = 0;
      const data = JSON.parse(res);

      if (!data.task) {
        await new Promise((r) => setTimeout(r, 1500));
        continue;
      }

      const task = data.task;
      // The server tags each task with the expert that requested it, so a
      // machine serving several rented experts can show who is doing what.
      const who = task.expertName || task.expertId || "expert";
      console.log(`\n=> [${who}] ${task.toolName}`);
      inFlight++;
      // Execute without blocking the poll loop, so more work can be picked up.
      void (async () => {
        try {
          const result = await executeTask(task);
          await makeRequest(
            `${config.host}/api/connector/respond`,
            { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${config.token}` } },
            JSON.stringify({ taskId: task.id, result })
          );
          console.log(`=> [${who}] done: ${task.toolName}`);
        } catch (e: any) {
          console.error(`=> [${who}] failed: ${task.toolName}: ${e.message}`);
        } finally {
          inFlight--;
        }
      })();
    } catch (e: any) {
      if (e.status === 401) {
        authFailures++;
        // Back off rather than exit: a rotated or briefly-expired token should
        // not require the operator to restart the daemon by hand.
        console.error(`=> Authentication rejected (${authFailures}). Re-run 'aclade-agent connect --token <token>' with a fresh token from the portal.`);
        if (authFailures >= 20) {
          console.error("=> Giving up after repeated auth failures.");
          process.exit(1);
        }
        await new Promise((r) => setTimeout(r, Math.min(60_000, 5_000 * authFailures)));
        continue;
      }
      await new Promise((r) => setTimeout(r, 3000));
    }
  }
}

/**
 * Re-running `connect` is the NORMAL way to rotate: the portal mints a fresh
 * 12-hour token each time. Without this, every rotation would strand a second
 * daemon polling on the stale token (401 storms plus split task delivery).
 * Best-effort, with a pid-reuse guard: we only signal a process whose command
 * line actually mentions aclade-agent. Windows can't read another process's
 * command line, so the check is skipped there (pm2 is the recommended runner
 * on Windows anyway).
 */
function stopPreviousDaemon() {
  try {
    const pid = parseInt(fs.readFileSync(PID_PATH, "utf8").trim(), 10);
    if (!Number.isFinite(pid) || pid <= 0 || pid === process.pid) return;
    if (process.platform === "win32") return;
    const cmdline = fs.readFileSync(`/proc/${pid}/cmdline`, "utf8");
    if (!cmdline.includes("aclade-agent")) return; // recycled pid — not ours
    console.log(`=> Stopping previous agent (pid ${pid})…`);
    process.kill(pid, "SIGTERM");
    const t0 = Date.now();
    const waitSlot = new Int32Array(new SharedArrayBuffer(4));
    while (Date.now() - t0 < 3000) {
      try { process.kill(pid, 0); } catch { return; } // gone
      Atomics.wait(waitSlot, 0, 0, 50);
    }
    try { process.kill(pid, "SIGKILL"); } catch { /* already gone */ }
  } catch {
    /* no pid file, unreadable, or the process is already gone — proceed */
  }
}

function writePidFile() {
  try { fs.writeFileSync(PID_PATH, String(process.pid)); } catch { /* best-effort */ }
}

function removePidFile() {
  try {
    if (fs.readFileSync(PID_PATH, "utf8").trim() === String(process.pid)) fs.unlinkSync(PID_PATH);
  } catch { /* best-effort */ }
}

function main() {
  const config = loadOrCreateConfig();
  if (!config.token) {
    console.error("Usage: aclade-agent connect --token <TOKEN> [--workspace /path/to/project]");
    console.error("Get your connection token from the Aclade portal under Settings → Local Agent.");
    process.exit(1);
  }
  workspaceRoot = config.workspaceRoot;

  if (!process.env.ACLADE_AGENT_DAEMON) {
    console.log("=> Starting aclade-agent in the background…");
    const logFile = fs.openSync(path.join(os.homedir(), ".aclade-agent.log"), "a");
    const child = child_process.spawn(process.execPath, [process.argv[1], ...process.argv.slice(2)], {
      detached: true,
      stdio: ["ignore", logFile, logFile],
      env: { ...process.env, ACLADE_AGENT_DAEMON: "1" },
    });
    child.unref();
    console.log("=> Running. You can close this terminal.");
    console.log(`=> Logs: ${path.join(os.homedir(), ".aclade-agent.log")}`);
    process.exit(0);
  } else {
    stopPreviousDaemon();
    writePidFile();
    process.on("exit", removePidFile);
    void pollLoop(config);
  }
}

main();
