import { createServer } from "node:http";
import { readFileSync, writeFileSync, existsSync, readdirSync } from "node:fs";
import { readFile, readdir, mkdir, writeFile as writeFileFs, rm, stat, unlink as unlinkFs, symlink as symlinkFs } from "node:fs/promises";
import { execFile, execFileSync, spawn } from "node:child_process";
import { promisify } from "node:util";
import { cpus, totalmem, freemem, uptime, hostname } from "node:os";
import { randomBytes, createHash } from "node:crypto";
import path from "node:path";

import { canAutoPruneMissingUnit } from "./app-reconcile-utils.mjs";

const exec = promisify(execFile);

const PORT = parseInt(process.env.MC_PORT || "19100");
const AUTH_TOKEN = process.env.MC_TOKEN;
const NANOCLAW_BASE = "/home/admin/treehouse/nanoclaw";
const NANOCLAW_SERVICE = "nanoclaw";
const NANOCLAW_WORKDIR = "/var/lib/nanoclaw";
const NANOCLAW_RUNTIME_DIR = process.env.NANOCLAW_RUNTIME_DIR || NANOCLAW_WORKDIR;
const NANOCLAW_COMPOSE_OVERRIDE_DIR = `${NANOCLAW_RUNTIME_DIR}/compose-overrides`;
const DB_PATH = `${NANOCLAW_WORKDIR}/store/messages.db`;
const HOOKS_PORT = parseInt(process.env.NANOCLAW_HOOKS_PORT || "19200");
const NIXOS_CONFIG = "/etc/nixos/configuration.nix";
const NIXOS_TENANTS_CONFIG = "/etc/nixos/tenants.nix";
const TENANT_HOME_BASE = "/home";
const VOLUME_DIR = "/var/lib/tenant-volumes";
const DISCORD_API = "https://discord.com/api/v10";
const DEFAULT_GUILD_ID = process.env.DISCORD_GUILD_ID;
const PORT_RANGE_START = 20000; // First tenant port range starts here
const PORT_RANGE_SIZE = 100;   // Ports per tenant
const CADDY_TENANT_DIR = "/etc/caddy/tenants";
const CONCIERGE_BOT_USER_ID = process.env.CONCIERGE_BOT_USER_ID || ""; // Discord user ID of the concierge bot (e.g. Gaia)
const CONCIERGE_TENANT_ID = process.env.CONCIERGE_TENANT_ID || "";
const MC_UI_BUILD_DIR = process.env.MC_UI_BUILD_DIR || "/home/admin/treehouse/mc-ui/build";
const MC_UI_DEV_URL = process.env.MC_UI_DEV_URL || ""; // e.g. http://localhost:5173
const TENANT_RUNTIME_HELPER = "/run/current-system/sw/bin/nanoclaw-tenant-runtime";

function parseTenantsNix(text) {
  const tenants = [];
  const re = /^\s*([a-zA-Z0-9-]+)\s*=\s*\{\s*uid\s*=\s*([0-9]+)\s*;/gm;
  let match;
  while ((match = re.exec(text))) {
    tenants.push({ tenant: match[1], uid: Number(match[2]) });
  }
  tenants.sort((a, b) => a.uid - b.uid);
  return tenants;
}

async function getTenantsFromNixosTenantsNix() {
  // /etc/nixos is symlinked to /home/admin/treehouse/nixos on this host.
  const nixFile = "/etc/nixos/tenants.nix";
  const text = await readFile(nixFile, "utf8");
  return parseTenantsNix(text);
}

function containerdSocket(uid) {
  return `/run/user/${uid}/containerd/containerd.sock`;
}

async function nerdctlPsAsTenant({ tenant, uid }) {
  const sock = containerdSocket(uid);
  const lineFormat = "{{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}";
  try {
    const { stdout } = await exec("sudo", [
      "-u",
      tenant,
      "sh",
      "-lc",
      [
        "cd ~",
        `sock='${sock}'`,
        `if [ ! -S \"$sock\" ]; then echo '__NOSOCK__' ; exit 0; fi`,
        `XDG_RUNTIME_DIR=/run/user/${uid} CONTAINERD_ADDRESS=$sock nerdctl ps --format '${lineFormat}'`,
      ].join(" && "),
    ]);

    if (String(stdout).trim() === "__NOSOCK__") return { ok: true, rows: [], note: `no socket at ${sock}` };
    const rows = String(stdout)
      .split("\n")
      .map((s) => s.trimEnd())
      .filter(Boolean)
      .map((line) => {
        const [name = "", image = "", status = "", ports = ""] = line.split("\t");
        return { name, image, status, ports };
      });
    return { ok: true, rows };
  } catch (e) {
    const stderr = e?.stderr ? String(e.stderr).trim() : "";
    return { ok: false, rows: [], error: stderr || e?.message || "nerdctl ps failed" };
  }
}

async function nerdctlInfoAsTenant({ tenant, uid }) {
  const sock = containerdSocket(uid);
  try {
    const { stdout } = await exec("sudo", [
      "-u",
      tenant,
      "sh",
      "-lc",
      [
        "cd ~",
        `sock='${sock}'`,
        `if [ ! -S \"$sock\" ]; then echo '__NOSOCK__' ; exit 0; fi`,
        `XDG_RUNTIME_DIR=/run/user/${uid} CONTAINERD_ADDRESS=$sock nerdctl info`,
      ].join(" && "),
    ]);
    if (String(stdout).trim() === "__NOSOCK__") return { ok: true, output: "", note: `no socket at ${sock}` };
    return { ok: true, output: String(stdout).trimEnd() };
  } catch (e) {
    const stderr = e?.stderr ? String(e.stderr).trim() : "";
    return { ok: false, output: "", error: stderr || e?.message || "nerdctl info failed" };
  }
}

async function nerdctlLogsAsTenant({ tenant, uid, name, tail = 200 }) {
  const sock = containerdSocket(uid);
  try {
    const { stdout } = await exec("sudo", [
      "-u",
      tenant,
      "sh",
      "-lc",
      [
        "cd ~",
        `sock='${sock}'`,
        `if [ ! -S \"$sock\" ]; then echo '__NOSOCK__' ; exit 0; fi`,
        `XDG_RUNTIME_DIR=/run/user/${uid} CONTAINERD_ADDRESS=$sock nerdctl logs --tail ${tail} '${name}'`,
      ].join(" && "),
    ]);
    if (String(stdout).trim() === "__NOSOCK__") return { ok: true, output: "", note: `no socket at ${sock}` };
    return { ok: true, output: String(stdout).trimEnd() };
  } catch (e) {
    const stderr = e?.stderr ? String(e.stderr).trim() : "";
    return { ok: false, output: "", error: stderr || e?.message || "nerdctl logs failed" };
  }
}

async function nerdctlDfAsTenant({ tenant, uid }) {
  const sock = containerdSocket(uid);
  try {
    const { stdout } = await exec("sudo", [
      "-u",
      tenant,
      "sh",
      "-lc",
      [
        "cd ~",
        `sock='${sock}'`,
        `if [ ! -S \"$sock\" ]; then echo '__NOSOCK__' ; exit 0; fi`,
        `XDG_RUNTIME_DIR=/run/user/${uid} CONTAINERD_ADDRESS=$sock nerdctl system info`,
        `echo`,
        `echo '--- images (top 50) ---'`,
        `XDG_RUNTIME_DIR=/run/user/${uid} CONTAINERD_ADDRESS=$sock nerdctl images | head -n 51`,
        `echo`,
        `echo '--- containers ---'`,
        `XDG_RUNTIME_DIR=/run/user/${uid} CONTAINERD_ADDRESS=$sock nerdctl ps -a`,
      ].join(" && "),
    ]);
    if (String(stdout).trim() === "__NOSOCK__") return { ok: true, output: "", note: `no socket at ${sock}` };
    return { ok: true, output: String(stdout).trimEnd() };
  } catch (e) {
    const stderr = e?.stderr ? String(e.stderr).trim() : "";
    return { ok: false, output: "", error: stderr || e?.message || "nerdctl df failed" };
  }
}

async function listAppsOnDisk(tenantId) {
  const appsDir = path.join(TENANT_HOME_BASE, tenantId, "workspace", "apps");
  try {
    const entries = await readdir(appsDir, { withFileTypes: true });
    return entries.filter((e) => e.isDirectory()).map((e) => e.name).sort();
  } catch {
    return [];
  }
}

async function systemctlUser(tenantId, uid, args) {
  return await sh("sudo", [
    "-u",
    tenantId,
    "sh",
    "-lc",
    [
      "cd ~",
      `export XDG_RUNTIME_DIR=/run/user/${uid}`,
      `export DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/${uid}/bus`,
      `systemctl --user ${args.map((a) => String(a)).join(" ")}`,
    ].join(" && "),
  ]);
}

function getTenantDockerNetworkName(tenantId) {
  return `nanoclaw-tenant-${tenantId}`;
}

async function getDockerBridgeSubnet(tenantId) {
  try {
    const { execFileSync } = await import("child_process");
    const out = execFileSync("docker", [
      "network", "inspect", getTenantDockerNetworkName(tenantId),
      "--format", "{{range .IPAM.Config}}{{.Subnet}}{{end}}"
    ], { encoding: "utf8", timeout: 5000 });
    return out.trim() || null;
  } catch { return null; }
}

function addProxyIptablesRule(subnet) {
  try {
    try {
      execFileSync("iptables", ["-C", "PROXY_ALLOW", "-s", subnet, "-j", "ACCEPT"], { timeout: 5000 });
      return; // already exists
    } catch {}
    execFileSync("iptables", ["-I", "PROXY_ALLOW", "1", "-s", subnet, "-j", "ACCEPT"], { timeout: 5000 });
    console.log("[proxy] Added ACCEPT rule for " + subnet);
  } catch (e) {
    console.error("[proxy] Failed to add iptables rule for " + subnet + ":", e.message);
  }
}

function removeProxyIptablesRule(subnet) {
  try {
    execFileSync("iptables", ["-D", "PROXY_ALLOW", "-s", subnet, "-j", "ACCEPT"], { timeout: 5000 });
    console.log("[proxy] Removed ACCEPT rule for " + subnet);
  } catch (e) {
    console.error("[proxy] Failed to remove iptables rule for " + subnet + ":", e.message);
  }
}

// Repopulate PROXY_ALLOW iptables rules from the authoritative NixOS tenants config.
// Called on MC startup and after nixos-rebuild (which flushes the chain).
// Source of truth is tailscaleProxy = true in /etc/nixos/tenants.nix — NOT proxy.json files,
// which are writable by tenant agents and cannot be trusted for access control decisions.
async function repopulateProxyIptablesRules() {
  try {
    const nixText = await readFile(NIXOS_TENANTS_CONFIG, "utf8");
    const re = /^\s*([a-zA-Z0-9-]+)\s*=\s*\{[^}]*tailscaleProxy\s*=\s*true/gm;
    let match;
    while ((match = re.exec(nixText))) {
      const tenantId = match[1];
      const subnet = await getDockerBridgeSubnet(tenantId);
      if (subnet) addProxyIptablesRule(subnet);
    }
    // Admin tenant always gets proxy access (not in tenants.nix)
    {
      const adminSubnet = await getDockerBridgeSubnet("admin");
      if (adminSubnet) addProxyIptablesRule(adminSubnet);
    }
  } catch (e) {
    console.error("[proxy] Failed to repopulate iptables rules:", e.message);
  }
}


// --- OAuth constants for credential pool ---
const OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
const OAUTH_AUTHORIZE_URL = "https://platform.claude.com/oauth/authorize";
const CLAUDE_AI_AUTHORIZE_URL = "https://claude.ai/oauth/authorize";
const OAUTH_TOKEN_URL = "https://platform.claude.com/v1/oauth/token";
const OAUTH_REDIRECT_URL = "https://platform.claude.com/oauth/code/callback";
const HOST_CODEX_AUTH_PATH = "/home/admin/.codex/auth.json";
const HOST_PI_AUTH_PATH = "/home/admin/.pi/agent/auth.json";
const OAUTH_SCOPES = "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers";
const CLAUDE_AI_SCOPES = "user:profile user:inference user:sessions:claude_code user:mcp_servers";
// Pending OAuth flows: credentialId -> { code_verifier, state, created_at }
const pendingOAuthFlows = new Map();
// Pending Codex device flows: credentialId -> { child, url, code, output, startedAt }
const pendingCodexDeviceFlows = new Map();
// Pending Pi OAuth login flows: credentialId -> { url, progress, done, error, cancelled }
const pendingPiLoginFlows = new Map();

function stripAnsi(s) {
  return s.replace(/\x1b\[[0-9;]*m/g, "");
}

function parseCodexDeviceAuthOutput(text) {
  const clean = stripAnsi(text);
  const url = clean.match(/https:\/\/\S+/);
  const code = clean.match(/(?<![A-Z0-9])([A-Z0-9]{4,5}-[A-Z0-9]{4,5})(?![A-Z0-9])/);
  return { url: url ? url[0].replace(/[.,;]+$/, "") : null, code: code ? code[1] : null };
}

function extractAccountId(authJsonRaw, provider) {
  if (!authJsonRaw) return null;
  try {
    const parsed = typeof authJsonRaw === "string" ? JSON.parse(authJsonRaw) : authJsonRaw;
    if ((provider || "claude") === "codex") {
      // Prefer email from id_token (distinguishes individual seats within a team account)
      const idToken = parsed?.tokens?.id_token;
      if (idToken) {
        const payload = JSON.parse(Buffer.from(idToken.split(".")[1], "base64url").toString());
        if (payload?.email) return payload.email;
      }
      return parsed?.tokens?.account_id ?? null;
    }
    // Claude: email or sub from access token
    const token = parsed?.claudeAiOauth?.access_token || parsed?.access_token;
    if (!token) return null;
    const payload = JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString());
    return payload?.email ?? payload?.sub ?? null;
  } catch {
    return null;
  }
}

function codexAuthExpiresAt(authJsonRaw) {
  try {
    const parsed = typeof authJsonRaw === "string" ? JSON.parse(authJsonRaw) : authJsonRaw;
    const token = parsed?.tokens?.access_token;
    if (!token) return null;
    const payload = JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString());
    if (!payload.exp) return null;
    return new Date(payload.exp * 1000).toISOString();
  } catch {
    return null;
  }
}

function loadHostCodexAuth() {
  const raw = readFileSync(HOST_CODEX_AUTH_PATH, "utf8");
  const parsed = JSON.parse(raw);
  if (!parsed || typeof parsed !== "object") {
    throw new Error("Host Codex auth.json is invalid");
  }
  if (!parsed.tokens || !parsed.tokens.access_token || !parsed.tokens.refresh_token) {
    throw new Error("Host Codex auth.json does not contain OAuth tokens");
  }
  return raw;
}

function loadHostPiAuth() {
  try {
    const raw = readFileSync(HOST_PI_AUTH_PATH, "utf8");
    JSON.parse(raw); // validate JSON
    return raw;
  } catch (err) {
    throw new Error(`Failed to read host pi auth: ${err.message}`);
  }
}

function generateCodeVerifier() {
  return randomBytes(64).toString("base64url");
}
async function generateCodeChallenge(verifier) {
  return createHash("sha256").update(verifier).digest("base64url");
}

// Push a fresh token to all active container token files for a credential.
// Reads all registered group IPC dirs from DB and overwrites .token.json.
async function pushTokenToContainerFiles(credId, accessToken) {
  try {
    const groups = await dbQuery(
      "SELECT folder FROM registered_groups WHERE tenant_id IS NOT NULL",
      []
    );
    const now = Date.now();
    let pushed = 0;
    for (const { folder } of groups) {
      // folder is "tenant/groupname"
      const parts = folder.split("/");
      if (parts.length < 2) continue;
      const [tenant, ...rest] = parts;
      const groupName = rest.join("/");
      const tokenPath = `${TENANT_HOME_BASE}/${tenant}/nanoclaw/data/ipc/${groupName}/.token.json`;
      try {
        if (!existsSync(tokenPath)) continue;
        const existing = JSON.parse(readFileSync(tokenPath, "utf8"));
        if (existing._credentialId !== credId) continue;
        existing.CLAUDE_CODE_OAUTH_TOKEN = accessToken;
        existing._updatedAt = now;
        writeFileSync(tokenPath, JSON.stringify(existing));
        pushed++;
      } catch {}
    }
    if (pushed > 0) console.log(`[mc] Pushed fresh token for ${credId} to ${pushed} container(s)`);
  } catch (err) {
    console.error(`[mc] pushTokenToContainerFiles error: ${err.message}`);
  }
}

// --- Discord bot token (lazy-loaded from nanoclaw .env) ---

let _discordToken = null;
let _conciergeTenantId = null;
async function getDiscordToken() {
  if (_discordToken) return _discordToken;
  try {
    const env = await readFile(`${NANOCLAW_WORKDIR}/.env`, "utf8");
    const match = env.match(/DISCORD_BOT_TOKEN=(.*)/);
    if (match) _discordToken = match[1].trim();
  } catch {}
  return _discordToken;
}

async function getConciergeTenantId() {
  if (_conciergeTenantId !== null) return _conciergeTenantId;
  // Check DB first
  try {
    const rows = await dbQuery("SELECT value FROM router_state WHERE key = 'concierge_tenant_id'");
    if (rows.length && rows[0].value) { _conciergeTenantId = rows[0].value; return _conciergeTenantId; }
  } catch {}
  if (CONCIERGE_TENANT_ID) {
    _conciergeTenantId = CONCIERGE_TENANT_ID;
    return _conciergeTenantId;
  }
  try {
    const env = await readFile(`${NANOCLAW_WORKDIR}/.env`, "utf8");
    const match = env.match(/CONCIERGE_TENANT_ID=(.*)/);
    _conciergeTenantId = match ? match[1].trim() : "";
  } catch {
    _conciergeTenantId = "";
  }
  return _conciergeTenantId;
}

async function getConciergesBotUserId() {
  try {
    const rows = await dbQuery("SELECT value FROM router_state WHERE key = 'concierge_bot_user_id'");
    if (rows.length && rows[0].value) return rows[0].value;
  } catch {}
  return CONCIERGE_BOT_USER_ID || null;
}

// --- Discord REST API helpers ---

async function discordApi(method, path, body) {
  const token = await getDiscordToken();
  if (!token) throw new Error("DISCORD_BOT_TOKEN not configured");
  return discordApiWithToken(token, method, path, body);
}

async function discordApiWithToken(token, method, path, body) {
  const opts = {
    method,
    headers: {
      Authorization: `Bot ${token}`,
      "Content-Type": "application/json",
    },
  };
  if (body) opts.body = JSON.stringify(body);
  const res = await fetch(`${DISCORD_API}${path}`, opts);
  if (res.status === 204) return null;
  const data = await res.json();
  if (!res.ok) {
    throw new Error(`Discord API error: ${data.message || JSON.stringify(data)} (${res.status}) [${method} ${path}]`);
  }
  return data;
}

async function getConciergeDiscordToken() {
  const conciergeTenantId = await getConciergeTenantId();
  if (!conciergeTenantId) return null;

  const rows = await dbQuery(
    "SELECT discord_bot_token FROM tenants WHERE id = ? AND tenant_type = 'bot'",
    [conciergeTenantId],
  );
  return rows[0]?.discord_bot_token || null;
}

async function sendDiscordDm(userId, content, preferredToken = null) {
  const token = preferredToken || await getDiscordToken();
  if (!token) throw new Error("No Discord bot token available for DM");

  const dmChannel = await discordApiWithToken(token, "POST", "/users/@me/channels", {
    recipient_id: userId,
  });
  await discordApiWithToken(token, "POST", `/channels/${dmChannel.id}/messages`, {
    content,
  });
}

function buildProvisioningInviteDm({ tenantName, inviteUrl }) {
  return [
    "You've been invited to TreeHouse, a private AI workspace where your own agent, apps, and dashboard live in one place.",
    "",
    `Your space: **${tenantName}**`,
    `Invite link: ${inviteUrl}`,
    "",
    "Once you join, Gaia will welcome you and unlock your private channels automatically.",
  ].join("\n");
}

async function getBotUser() {
  return discordApi("GET", "/users/@me");
}

async function createCategory(guildId, name) {
  return discordApi("POST", `/guilds/${guildId}/channels`, {
    name,
    type: 4, // GUILD_CATEGORY
  });
}

async function createTextChannel(guildId, name, categoryId) {
  return discordApi("POST", `/guilds/${guildId}/channels`, {
    name,
    type: 0, // GUILD_TEXT
    parent_id: categoryId,
  });
}

async function createRole(guildId, name) {
  return discordApi("POST", `/guilds/${guildId}/roles`, {
    name,
    mentionable: false,
    hoist: false,
  });
}

// Permission bits
const MANAGE_CHANNELS = 1n << 4n;
const VIEW_CHANNEL = 1n << 10n;
const SEND_MESSAGES = 1n << 11n;
const READ_MESSAGE_HISTORY = 1n << 16n;
const TENANT_ALLOW = MANAGE_CHANNELS | VIEW_CHANNEL | SEND_MESSAGES | READ_MESSAGE_HISTORY;

async function lockCategoryPermissions(categoryId, guildId, botUserId, gaiaBotUserId) {
  // Allow platform bot
  await discordApi("PUT", `/channels/${categoryId}/permissions/${botUserId}`, {
    id: botUserId,
    type: 1, // member
    allow: TENANT_ALLOW.toString(),
    deny: "0",
  });
  // Allow Gaia bot (concierge access to all tenant channels)
  if (gaiaBotUserId) {
    await discordApi("PUT", `/channels/${categoryId}/permissions/${gaiaBotUserId}`, {
      id: gaiaBotUserId,
      type: 1, // member
      allow: TENANT_ALLOW.toString(),
      deny: "0",
    });
  }
  // Deny @everyone from seeing the category
  await discordApi("PUT", `/channels/${categoryId}/permissions/${guildId}`, {
    id: guildId,
    type: 0, // role
    deny: TENANT_ALLOW.toString(),
    allow: "0",
  });
}

/** Grant a Discord user access to a tenant's category */
async function grantUserCategoryAccess(categoryId, userId) {
  await discordApi("PUT", `/channels/${categoryId}/permissions/${userId}`, {
    id: userId,
    type: 1, // member
    allow: TENANT_ALLOW.toString(),
    deny: "0",
  });
}

async function createInvite(channelId) {
  return discordApi("POST", `/channels/${channelId}/invites`, {
    max_age: 0, // never expires
    max_uses: 0, // unlimited
    unique: true,
  });
}

async function deleteDiscordChannel(channelId) {
  return discordApi("DELETE", `/channels/${channelId}`);
}

async function deleteRole(guildId, roleId) {
  return discordApi("DELETE", `/guilds/${guildId}/roles/${roleId}`);
}

// Get all channels in a category
async function getCategoryChannels(guildId, categoryId) {
  const channels = await discordApi("GET", `/guilds/${guildId}/channels`);
  return channels.filter(c => c.parent_id === categoryId);
}

// --- NixOS config helpers ---

async function getNextUid() {
  const config = await readFile(NIXOS_TENANTS_CONFIG, "utf8");
  const uidMatches = [...config.matchAll(/uid\s*=\s*(\d+)/g)];
  const uids = uidMatches.map(m => parseInt(m[1]));
  return Math.max(2000, ...uids) + 1;
}

async function addTenantToNixConfig(tenantId, { maxMemoryMB = 4096, cpuQuota = "200%", cpuWeight = 100 } = {}) {
  const uid = await getNextUid();
  const config = await readFile(NIXOS_TENANTS_CONFIG, "utf8");
  const tenantsMatch = config.match(/^( *).*tenants\s*=\s*\{/m);
  if (!tenantsMatch) throw new Error("Could not find tenants block in NixOS tenants config");
  const tenantsIdx = tenantsMatch.index;
  const indent = tenantsMatch[1];

  let depth = 0;
  let closeIdx = -1;
  for (let i = tenantsIdx; i < config.length; i++) {
    if (config[i] === "{") depth++;
    else if (config[i] === "}") {
      depth--;
      if (depth === 0) { closeIdx = i; break; }
    }
  }
  if (closeIdx === -1) throw new Error("Could not find end of tenants block");

  const tenantsBlock = config.slice(tenantsIdx, closeIdx);
  if (new RegExp(`\\b${tenantId}\\s*=`).test(tenantsBlock)) {
    throw new Error(`Tenant '${tenantId}' already exists in NixOS config`);
  }

  let insertIdx = closeIdx;
  while (insertIdx > 0 && config[insertIdx - 1] !== "\n") insertIdx--;
  const tenantEntry = `${indent}  ${tenantId} = { uid = ${uid}; maxMemoryMB = ${maxMemoryMB}; cpuQuota = "${cpuQuota}"; cpuWeight = ${cpuWeight}; };\n`;
  const updated = config.slice(0, insertIdx) + tenantEntry + config.slice(insertIdx);
  await writeFileFs(NIXOS_TENANTS_CONFIG, updated);
  return uid;
}

async function removeTenantFromNixConfig(tenantId) {
  const config = await readFile(NIXOS_TENANTS_CONFIG, "utf8");
  // Match tenant entries with any attributes inside braces
  const pattern = new RegExp(`\\n?\\s*${tenantId}\\s*=\\s*\\{[^}]*\\};`, "g");
  const updated = config.replace(pattern, "");
  await writeFileFs(NIXOS_TENANTS_CONFIG, updated);
}

async function updateTenantNixConfig(tenantId, opts) {
  const config = await readFile(NIXOS_TENANTS_CONFIG, "utf8");
  const pattern = new RegExp(`(${tenantId}\\s*=\\s*\\{)([^}]*)(\\})`);
  const match = pattern.exec(config);
  if (!match) throw new Error(`Tenant '${tenantId}' not found in NixOS config`);

  // Parse existing attributes from the matched block
  const attrs = match[2];
  const uid = attrs.match(/uid\s*=\s*(\d+)/)?.[1];
  if (!uid) throw new Error(`Tenant '${tenantId}' has no uid in NixOS config`);

  const maxMemoryMB = opts.maxMemoryMB ?? (attrs.match(/maxMemoryMB\s*=\s*(\d+)/)?.[1] || "4096");
  const cpuQuota = opts.cpuQuota ?? (attrs.match(/cpuQuota\s*=\s*"([^"]+)"/)?.[1] || "200%");
  const cpuWeight = opts.cpuWeight ?? (attrs.match(/cpuWeight\s*=\s*(\d+)/)?.[1] || "100");
  const existingTailscaleProxy = /tailscaleProxy\s*=\s*true/.test(attrs);
  const tailscaleProxy = opts.tailscaleProxy !== undefined ? opts.tailscaleProxy : existingTailscaleProxy;

  let newEntry = `${tenantId} = { uid = ${uid}; maxMemoryMB = ${maxMemoryMB}; cpuQuota = "${cpuQuota}"; cpuWeight = ${cpuWeight};`;
  if (tailscaleProxy) newEntry += ` tailscaleProxy = true;`;
  newEntry += ` }`;
  const updated = config.replace(new RegExp(`${tenantId}\\s*=\\s*\\{[^}]*\\}`), newEntry);
  await writeFileFs(NIXOS_TENANTS_CONFIG, updated);
}

async function nixosRebuildSwitch() {
  const result = await sh("nixos-rebuild", ["switch"], { timeout: 300000 });
  if (!result.ok && result.stderr.includes("activating the configuration")) {
    result.ok = true;
  }
  return result;
}

// --- Volume management ---

async function createVolume(tenantId, sizeMb) {
  const imgPath = `${VOLUME_DIR}/${tenantId}.img`;
  const mountPoint = `${TENANT_HOME_BASE}/${tenantId}`;

  // Skip if volume already exists (e.g. partially provisioned tenant)
  const exists = await sh("test", ["-f", imgPath]);
  if (exists.ok) return;

  // Start with desired size + generous headroom for ext4 overhead
  let imgMb = sizeMb + Math.ceil(sizeMb * 0.1) + 20;
  const trunc = await sh("truncate", ["-s", `${imgMb}M`, imgPath]);
  if (!trunc.ok) throw new Error(`Failed to create volume image: ${trunc.stderr}`);
  const fmt = await sh("mkfs.ext4", ["-m", "1", "-q", "-F", imgPath]);
  if (!fmt.ok) throw new Error(`Failed to format volume: ${fmt.stderr}`);
  // Reserve the 1% for the nanoclaw service user so agent pre-launch writes
  // always succeed even when tenant workloads have filled the user-accessible 99%.
  await sh("tune2fs", ["-u", "nanoclaw", imgPath]);

  // Mount, measure actual available space, then grow if needed to hit target
  await sh("mkdir", ["-p", mountPoint]);
  const mnt = await sh("mount", ["-o", "loop", imgPath, mountPoint]);
  if (!mnt.ok) throw new Error(`Failed to mount volume: ${mnt.stderr}`);

  const dfResult = await sh("df", ["-BM", "--output=avail", mountPoint]);
  await sh("umount", [mountPoint]);

  if (dfResult.ok) {
    const availMb = parseInt(dfResult.stdout.split("\n")[1]);
    if (availMb < sizeMb) {
      const deficit = sizeMb - availMb;
      imgMb += deficit;
      await sh("truncate", ["-s", `${imgMb}M`, imgPath]);
      // Resize the filesystem to fill the new image
      const lo = await sh("losetup", ["--find", "--show", imgPath]);
      if (lo.ok) {
        const dev = lo.stdout.trim();
        await sh("e2fsck", ["-fy", dev]);
        await sh("resize2fs", [dev]);
        await sh("losetup", ["-d", dev]);
      }
    }
  }
}

async function resizeVolume(tenantId, newSizeMb) {
  const imgPath = `${VOLUME_DIR}/${tenantId}.img`;
  const mountPoint = `${TENANT_HOME_BASE}/${tenantId}`;
  const log = (msg) => console.log(`[volume:${tenantId}] ${msg}`);

  // Helper: attach a NEW loop device, do work, always detach (for offline ops)
  async function withLoop(fn) {
    const lo = await sh("losetup", ["--find", "--show", imgPath]);
    if (!lo.ok) throw new Error(`losetup failed: ${lo.stderr}`);
    const dev = lo.stdout.trim();
    try { return await fn(dev); } finally { await sh("losetup", ["-d", dev]); }
  }

  // Helper: find the loop device already backing imgPath (live-mounted volume)
  async function findLiveLoop() {
    const lo = await sh("losetup", ["-j", imgPath]);
    if (!lo.ok || !lo.stdout.trim()) return null;
    return lo.stdout.trim().split(":")[0]; // e.g. "/dev/loop4"
  }

  // Helper: measure total usable space (used+avail) while mounted
  async function measureUsable() {
    const df = await sh("df", ["-BM", "--output=used,avail", mountPoint]);
    if (!df.ok) throw new Error(`df failed: ${df.stderr}`);
    const parts = df.stdout.split("\n")[1].trim().split(/\s+/);
    return parseInt(parts[0]) + parseInt(parts[1]);
  }

  // Check current usage
  const curUsed = await sh("df", ["-BM", "--output=used", mountPoint]);
  const usedMb = curUsed.ok ? parseInt(curUsed.stdout.split("\n")[1]) : 0;
  if (newSizeMb < usedMb + 5) {
    throw new Error(`Cannot shrink: ${usedMb}MB in use, minimum is ${usedMb + 5}MB`);
  }

  log(`Resizing to ${newSizeMb}MB usable (${usedMb}MB in use)`);

  const statResult = await sh("stat", ["--format=%s", imgPath]);
  if (!statResult.ok) throw new Error(`Volume not found: ${statResult.stderr}`);
  const currentImgMb = Math.round(parseInt(statResult.stdout) / 1024 / 1024);

  // Initial target image size (generous estimate)
  let imgMb = newSizeMb + Math.ceil(newSizeMb * 0.1) + 20;
  log(`Current image: ${currentImgMb}MB, target image: ${imgMb}MB`);

  if (imgMb >= currentImgMb) {
    // Growing: do it online — no unmount needed
    log(`Growing image to ${imgMb}M (online)`);
    await sh("truncate", ["-s", `${imgMb}M`, imgPath]);
    const dev = await findLiveLoop();
    if (dev) {
      await sh("losetup", ["--set-capacity", dev]);
      const r = await sh("resize2fs", [dev]);
      if (!r.ok) throw new Error(`resize2fs grow failed: ${r.stderr}`);
    } else {
      await withLoop(async (d) => {
        const r = await sh("resize2fs", [d]);
        if (!r.ok) throw new Error(`resize2fs grow failed: ${r.stderr}`);
      });
    }

    const usable = await measureUsable();
    log(`After initial resize: ${usable}MB usable, target: ${newSizeMb}MB`);

    // Correction pass: only grow if we undershot (overshoot is benign).
    // Never shrink the file here — truncating without first shrinking ext4
    // leaves the filesystem larger than the device, causing I/O errors.
    const diff = newSizeMb - usable;
    if (diff > 2) {
      log(`Correcting by ${diff}MB`);
      imgMb += diff;
      await sh("truncate", ["-s", `${imgMb}M`, imgPath]);
      const livedev = await findLiveLoop();
      if (livedev) {
        await sh("losetup", ["--set-capacity", livedev]);
        await sh("resize2fs", [livedev]);
      } else {
        await withLoop(async (d) => { await sh("resize2fs", [d]); });
      }
      const final = await measureUsable();
      log(`Final: ${final}MB usable`);
    }
  } else {
    // Shrinking: must unmount first (cannot shrink a live filesystem)
    const umount = await sh("umount", [mountPoint]);
    if (!umount.ok) throw new Error(`Cannot unmount for shrink: ${umount.stderr}`);

    try {
      log(`Shrinking fs to ${imgMb}M`);
      await withLoop(async (dev) => {
        await sh("e2fsck", ["-fy", dev]);
        const r = await sh("resize2fs", [dev, `${imgMb}M`]);
        if (!r.ok) throw new Error(`resize2fs shrink failed: ${r.stderr}`);
      });
      await sh("truncate", ["-s", `${imgMb}M`, imgPath]);

      await sh("mount", ["-o", "loop", imgPath, mountPoint]);
      const usable = await measureUsable();
      log(`After initial resize: ${usable}MB usable, target: ${newSizeMb}MB`);

      // Correction pass
      const diff = newSizeMb - usable;
      if (Math.abs(diff) > 2) {
        log(`Correcting by ${diff}MB`);
        imgMb += diff;
        await sh("umount", [mountPoint]);

        if (diff < 0) {
          await withLoop(async (dev) => {
            await sh("e2fsck", ["-fy", dev]);
            await sh("resize2fs", [dev, `${imgMb}M`]);
          });
          await sh("truncate", ["-s", `${imgMb}M`, imgPath]);
        } else {
          await sh("truncate", ["-s", `${imgMb}M`, imgPath]);
          await withLoop(async (dev) => { await sh("resize2fs", [dev]); });
        }

        await sh("mount", ["-o", "loop", imgPath, mountPoint]);
        const final = await measureUsable();
        log(`Final: ${final}MB usable`);
      }
    } catch (e) {
      log(`Error: ${e.message}`);
      await sh("mount", ["-o", "loop", imgPath, mountPoint]);
      throw e;
    }
  }
}

async function deleteVolume(tenantId) {
  const imgPath = `${VOLUME_DIR}/${tenantId}.img`;
  const mountPoint = `${TENANT_HOME_BASE}/${tenantId}`;
  // Unmount if mounted
  await sh("umount", [mountPoint]);
  // Delete image file
  await sh("rm", ["-f", imgPath]);
}

async function getVolumeInfo(tenantId) {
  const imgPath = `${VOLUME_DIR}/${tenantId}.img`;
  const mountPoint = `${TENANT_HOME_BASE}/${tenantId}`;

  const stat = await sh("stat", ["--format=%s", imgPath]);
  if (!stat.ok) return null;

  const dfResult = await sh("df", ["-B1", "--output=size,used,avail", mountPoint]);
  let used = 0, total = 0, avail = 0;
  if (dfResult.ok) {
    const lines = dfResult.stdout.split("\n");
    if (lines.length >= 2) {
      const parts = lines[1].trim().split(/\s+/);
      total = parseInt(parts[0]);
      used = parseInt(parts[1]);
      avail = parseInt(parts[2]);
    }
  }

  return {
    image_size_mb: Math.round(parseInt(stat.stdout) / 1024 / 1024),
    used_mb: Math.round(used / 1024 / 1024),
    avail_mb: Math.round(avail / 1024 / 1024),
    total_mb: Math.round(total / 1024 / 1024),
  };
}

// --- Port range allocation ---

async function allocatePortRange() {
  // Find the highest port_range_start currently in use
  const rows = await dbQuery(
    "SELECT port_range_start FROM tenants WHERE port_range_start IS NOT NULL ORDER BY port_range_start DESC LIMIT 1"
  );
  if (rows.length > 0 && rows[0].port_range_start) {
    return rows[0].port_range_start + PORT_RANGE_SIZE;
  }
  return PORT_RANGE_START;
}

// --- Caddy tenant config ---

async function writeTenantCaddyfile(tenantId, portRangeStart, portRangeSize) {
  // Create an empty Caddyfile placeholder — the agent will populate it via the skill
  const caddyPath = `${CADDY_TENANT_DIR}/${tenantId}.caddy`;
  const content = `# Caddy config for tenant: ${tenantId}
# Port range: ${portRangeStart}-${portRangeStart + portRangeSize - 1}

# Tenant dashboard
${tenantId}.devitltd.uk {
    reverse_proxy localhost:19100
}
`;
  await writeFileFs(caddyPath, content);
  // Make it writable by nanoclaw service (which manages it on behalf of agents)
  await sh("chown", ["root:nanoclaw", caddyPath]);
  await sh("chmod", ["664", caddyPath]);
  await sh("systemctl", ["reload", "caddy"]);
}

async function readTenantCaddyfile(tenantId) {
  try {
    return await readFile(`${CADDY_TENANT_DIR}/${tenantId}.caddy`, "utf8");
  } catch {
    return null;
  }
}

async function updateTenantCaddyfile(tenantId, content) {
  const caddyPath = `${CADDY_TENANT_DIR}/${tenantId}.caddy`;
  await writeFileFs(caddyPath, content);
  // Reload Caddy to pick up changes
  await sh("systemctl", ["reload", "caddy"]);
}

async function deleteTenantCaddyfile(tenantId) {
  await sh("rm", ["-f", `${CADDY_TENANT_DIR}/${tenantId}.caddy`]);
  await sh("systemctl", ["reload", "caddy"]);
}

async function getTenantServices(tenantId) {
  const storedServices = await dbQuery(
    "SELECT subdomain, config FROM tenant_services WHERE tenant_id = ? ORDER BY subdomain",
    [tenantId],
  );
  const parsedStoredServices = storedServices.map((row) => {
    try {
      return {
        subdomain: row.subdomain,
        config: JSON.parse(row.config),
      };
    } catch {
      return {
        subdomain: row.subdomain,
        config: {},
      };
    }
  });

  const runtimeUnits = ["nanoclaw-containerd.service", "nanoclaw-buildkitd.service"];
  const runtime = {};
  for (const unit of runtimeUnits) {
    const status = await sh(
      "sudo",
      [
        TENANT_RUNTIME_HELPER,
        tenantId,
        "systemctl-user",
        "show",
        unit,
        "--property=ActiveState,SubState,Result",
      ],
      { timeout: 10000 },
    );
    let activeState = "unknown";
    let subState = "unknown";
    let result = "unknown";
    if (status.ok && status.stdout) {
      for (const line of status.stdout.split("\n")) {
        if (line.startsWith("ActiveState=")) activeState = line.split("=")[1] || activeState;
        if (line.startsWith("SubState=")) subState = line.split("=")[1] || subState;
        if (line.startsWith("Result=")) result = line.split("=")[1] || result;
      }
    } else {
      activeState = "inactive";
      subState = "not-found";
      result = "failed";
    }
    runtime[unit] = { activeState, subState, result };
  }

  const services = [];
  for (const entry of parsedStoredServices.filter(
    (item) => String(item.config.runtime || "") === "nerdctl",
  )) {
    const serviceName = entry.config.serviceUnitName || "";
    let activeState = "unknown";
    let subState = "unknown";
    let result = "unknown";
    if (serviceName) {
      const status = await sh(
        "sudo",
        [
          TENANT_RUNTIME_HELPER,
          tenantId,
          "systemctl-user",
          "show",
          serviceName,
          "--property=ActiveState,SubState,Result",
        ],
        { timeout: 10000 },
      );
      if (status.ok && status.stdout) {
        for (const line of status.stdout.split("\n")) {
          if (line.startsWith("ActiveState=")) activeState = line.split("=")[1] || activeState;
          if (line.startsWith("SubState=")) subState = line.split("=")[1] || subState;
          if (line.startsWith("Result=")) result = line.split("=")[1] || result;
        }
      } else {
        activeState = "inactive";
        subState = "not-found";
        result = "failed";
      }
    }

    const port = entry.config.port || null;
    const subdomain = entry.subdomain;
    const localUrl = port != null ? `http://127.0.0.1:${port}` : null;
    const publicUrl = subdomain ? `https://${subdomain}.${tenantId}.devitltd.uk` : null;
    const localProbe = await probeHttpUrl(localUrl);
    const publicProbe = await probeHttpUrl(publicUrl);
    const recentLogs = serviceName
      ? await getTenantUserUnitLogs(tenantId, serviceName, 20)
      : "";

    services.push({
      Name: entry.config.name || entry.subdomain,
      Runtime: "nerdctl",
      Subdomain: entry.subdomain,
      ServiceName: serviceName,
      State: activeState,
      Status: subState,
      Result: result,
      Port: port,
      PublicService: entry.config.publicService || null,
      ProjectName: entry.config.projectName || null,
      LocalUrl: localUrl,
      PublicUrl: publicUrl,
      LocalProbe: localProbe,
      PublicProbe: publicProbe,
      RecentLogs: recentLogs,
    });
  }

  return {
    hasCompose: false,
    hasNerdctl: services.length > 0,
    runtime,
    services,
    composeContent: "",
  };
}

async function getTenantUserUnitLogs(tenantId, unitName, lines = 20) {
  try {
    const uidResult = await sh("id", ["-u", tenantId], { timeout: 5000 });
    if (!uidResult.ok || !uidResult.stdout) return "";
    const uid = uidResult.stdout.trim();
    const logs = await sh(
      "sudo",
      [
        "runuser",
        "-u",
        tenantId,
        "--",
        "env",
        `XDG_RUNTIME_DIR=/run/user/${uid}`,
        `DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/${uid}/bus`,
        "journalctl",
        "--user",
        "-u",
        unitName,
        "-n",
        String(lines),
        "--no-pager",
        "-o",
        "cat",
      ],
      { timeout: 10000 },
    );
    return logs.ok ? logs.stdout.trim() : logs.stderr.trim();
  } catch {
    return "";
  }
}

async function probeHttpUrl(url) {
  if (!url) {
    return { ok: false, status: null, error: "not-configured", preview: "" };
  }
  try {
    const res = await fetch(url, {
      redirect: "follow",
      signal: AbortSignal.timeout(4000),
    });
    const text = await res.text();
    const preview = String(text || "").replace(/\s+/g, " ").trim().slice(0, 140);
    return {
      ok: res.ok,
      status: res.status,
      error: "",
      preview,
    };
  } catch (error) {
    return {
      ok: false,
      status: null,
      error: error?.message || "request-failed",
      preview: "",
    };
  }
}

function parseTenantAppNameFromContainer(tenantId, containerName) {
  const prefix = `nanoclaw-${tenantId}-`;
  if (!containerName.startsWith(prefix)) return null;
  const rest = containerName.slice(prefix.length);
  const parts = rest.split("-");
  if (parts.length >= 3) {
    return parts.slice(0, -2).join("-") || parts[0] || null;
  }
  return parts[0] || null;
}

function parseTenantAppNameFromUnitName(unitName) {
  const match = /^nanoclaw-app-(.+)\.service$/.exec(unitName);
  return match ? match[1] : null;
}

function extractHostPorts(portsField) {
  const ports = new Set();
  for (const match of String(portsField || "").matchAll(/127\.0\.0\.1:(\d+)->/g)) {
    ports.add(Number(match[1]));
  }
  return [...ports].sort((a, b) => a - b);
}

function parseTenantCaddyRoutes(tenantId, content) {
  const routes = new Map();
  if (!content) return routes;
  const lines = String(content).split("\n");
  let currentSubdomain = null;
  for (const line of lines) {
    const hostMatch = line.match(
      new RegExp(`^([a-z0-9][a-z0-9-]*)\\.${tenantId}\\.devitltd\\.uk\\s*\\{`),
    );
    if (hostMatch) {
      currentSubdomain = hostMatch[1];
      continue;
    }
    if (!currentSubdomain) continue;
    const portMatch = line.match(/reverse_proxy\s+(?:localhost|127\.0\.0\.1):(\d+)/);
    if (portMatch) {
      routes.set(currentSubdomain, {
        subdomain: currentSubdomain,
        port: Number(portMatch[1]),
        publicUrl: `https://${currentSubdomain}.${tenantId}.devitltd.uk`,
      });
      currentSubdomain = null;
      continue;
    }
    if (line.trim() === "}") {
      currentSubdomain = null;
    }
  }
  return routes;
}

async function listTenantAppUnitFiles(tenantId) {
  const unitDir = path.join(TENANT_HOME_BASE, tenantId, ".config", "systemd", "user");
  try {
    const entries = await readdir(unitDir, { withFileTypes: true });
    return entries
      .filter((entry) => entry.isFile() && /^nanoclaw-app-.+\.service$/.test(entry.name))
      .map((entry) => entry.name)
      .sort();
  } catch {
    return [];
  }
}

async function getTenantUserUnitState(tenantId, uid, unitName) {
  const status = await systemctlUser(tenantId, uid, [
    "show",
    unitName,
    "--property=LoadState,UnitFileState,ActiveState,SubState,Result",
  ]);
  const result = {
    loadState: "not-found",
    unitFileState: "unknown",
    activeState: "unknown",
    subState: "unknown",
    result: "unknown",
  };
  if (!status.ok || !status.stdout) return result;
  for (const line of status.stdout.split("\n")) {
    if (line.startsWith("LoadState=")) result.loadState = line.split("=")[1] || result.loadState;
    if (line.startsWith("UnitFileState=")) result.unitFileState = line.split("=")[1] || result.unitFileState;
    if (line.startsWith("ActiveState=")) result.activeState = line.split("=")[1] || result.activeState;
    if (line.startsWith("SubState=")) result.subState = line.split("=")[1] || result.subState;
    if (line.startsWith("Result=")) result.result = line.split("=")[1] || result.result;
  }
  return result;
}

async function listTenantHostingStatusArtifacts(tenantId) {
  const artifacts = new Map();
  const ipcBase = path.join(TENANT_HOME_BASE, tenantId, "nanoclaw", "data", "ipc");
  try {
    const groupDirs = await readdir(ipcBase, { withFileTypes: true });
    for (const groupDir of groupDirs) {
      if (!groupDir.isDirectory() || groupDir.name === "errors") continue;
      const statusDir = path.join(ipcBase, groupDir.name, "hosting-status");
      let files = [];
      try {
        files = await readdir(statusDir);
      } catch {
        continue;
      }
      for (const file of files) {
        if (!file.endsWith(".json")) continue;
        if (/-(?:logs|exec|result)\.json$/.test(file)) continue;
        if (file.startsWith("app-auth-")) continue;
        const appName = file.replace(/\.json$/, "");
        const filePath = path.join(statusDir, file);
        let data = null;
        try {
          data = JSON.parse(await readFile(filePath, "utf8"));
        } catch {
          data = null;
        }
        const list = artifacts.get(appName) || [];
        list.push({ group: groupDir.name, path: filePath, data });
        artifacts.set(appName, list);
      }
    }
  } catch {
    return artifacts;
  }
  return artifacts;
}

async function readTenantAppSpecSummary(tenantId, appName) {
  const appDir = path.join(TENANT_HOME_BASE, tenantId, "workspace", "apps", appName);
  const specPath = path.join(appDir, "app.yml");
  const legacySpecPath = path.join(appDir, "nanoclaw.app.yml");
  const resolvedPath = existsSync(specPath) ? specPath : (existsSync(legacySpecPath) ? legacySpecPath : null);
  if (!resolvedPath) {
    return {
      appDir,
      exists: false,
      specPath,
      legacySpecPath,
      hasLegacyPorts: false,
    };
  }
  let content = "";
  try {
    content = await readFile(resolvedPath, "utf8");
  } catch {
    content = "";
  }
  return {
    appDir,
    exists: true,
    specPath: resolvedPath,
    legacySpecPath,
    hasLegacyPorts: /^\s+ports:\s*$/m.test(content),
  };
}

async function getRawTenantServiceRows(tenantId) {
  const rows = await dbQuery(
    "SELECT subdomain, config, updated_at FROM tenant_services WHERE tenant_id = ? ORDER BY subdomain",
    [tenantId],
  );
  return rows.map((row) => {
    let config = {};
    try {
      config = JSON.parse(row.config);
    } catch {
      config = {};
    }
    const appName = String(config.name || row.subdomain || "").trim() || row.subdomain;
    return {
      appName,
      subdomain: row.subdomain,
      config,
      updatedAt: row.updated_at || null,
    };
  });
}

function summarizeTenantAppIssues(apps) {
  const issueCounts = {};
  let warn = 0;
  let fail = 0;
  for (const app of apps) {
    for (const issue of app.issues) {
      issueCounts[issue.code] = (issueCounts[issue.code] || 0) + 1;
      if (issue.severity === "fail") fail += 1;
      else warn += 1;
    }
  }
  return {
    apps: apps.length,
    warn,
    fail,
    issueCounts,
  };
}

function deriveEffectiveAppStatus({ appName, latestStatus, unit, containers, publicUrl, subdomain, hostPort }) {
  const containerCount = Array.isArray(containers) ? containers.length : 0;
  const runtimeReady = !!unit?.present && unit.activeState === "active" && containerCount > 0;

  if (latestStatus?.stage === "building" && runtimeReady) {
    return {
      ...latestStatus,
      ok: true,
      stage: "deployed",
      inferred: true,
      inferredFrom: "runtime_ready_while_artifact_building",
      rawStage: latestStatus.stage,
      publicUrl: latestStatus.publicUrl || publicUrl || null,
      subdomain: latestStatus.subdomain || subdomain || null,
      hostPort: latestStatus.hostPort || hostPort || null,
    };
  }

  if (!latestStatus && runtimeReady) {
    return {
      appName,
      ok: true,
      stage: "deployed",
      inferred: true,
      inferredFrom: "runtime_ready_without_status_artifact",
      publicUrl: publicUrl || null,
      subdomain: subdomain || null,
      hostPort: hostPort || null,
      updatedAt: null,
    };
  }

  return latestStatus || null;
}

async function inspectTenantApps(tenantId, options = {}) {
  const appFilter = options.app ? String(options.app) : "";
  const tenant = (await getTenantsFromNixosTenantsNix()).find((t) => t.tenant === tenantId);
  if (!tenant) {
    return { tenant: null, apps: [], summary: summarizeTenantAppIssues([]) };
  }

  const [dbServices, appDirs, caddyContent, ps, unitFiles, artifacts] = await Promise.all([
    getRawTenantServiceRows(tenantId),
    listAppsOnDisk(tenantId),
    readTenantCaddyfile(tenantId),
    nerdctlPsAsTenant(tenant),
    listTenantAppUnitFiles(tenantId),
    listTenantHostingStatusArtifacts(tenantId),
  ]);
  const caddyRoutes = parseTenantCaddyRoutes(tenantId, caddyContent || "");

  const unitStates = new Map();
  await Promise.all(unitFiles.map(async (unitName) => {
    unitStates.set(unitName, await getTenantUserUnitState(tenantId, tenant.uid, unitName));
  }));

  const containersByApp = new Map();
  for (const row of Array.isArray(ps.rows) ? ps.rows : []) {
    const appName = parseTenantAppNameFromContainer(tenantId, row.name || "");
    if (!appName) continue;
    const list = containersByApp.get(appName) || [];
    list.push({
      name: row.name,
      image: row.image,
      status: row.status,
      ports: row.ports,
      hostPorts: extractHostPorts(row.ports),
    });
    containersByApp.set(appName, list);
  }

  const appNames = new Set(appDirs);
  for (const row of dbServices) appNames.add(row.appName);
  for (const subdomain of caddyRoutes.keys()) appNames.add(subdomain);
  for (const unitName of unitFiles) {
    const appName = parseTenantAppNameFromUnitName(unitName);
    if (appName) appNames.add(appName);
  }
  for (const appName of containersByApp.keys()) appNames.add(appName);
  for (const appName of artifacts.keys()) appNames.add(appName);

  const portOwners = new Map();
  for (const row of dbServices) {
    const port = Number(row.config.port);
    if (!Number.isInteger(port) || port <= 0) continue;
    const owners = portOwners.get(port) || [];
    owners.push(row.appName);
    portOwners.set(port, owners);
  }

  const apps = [];
  for (const appName of [...appNames].sort()) {
    if (appFilter && appName !== appFilter) continue;
    const db = dbServices.find((row) => row.appName === appName) || null;
    const route =
      caddyRoutes.get(db?.subdomain || "") ||
      caddyRoutes.get(appName) ||
      null;
    const unitName = `nanoclaw-app-${appName}.service`;
    const unit = unitStates.has(unitName)
      ? { present: true, unitName, ...unitStates.get(unitName) }
      : { present: false, unitName, loadState: "not-found", unitFileState: "not-found", activeState: "not-found", subState: "not-found", result: "unknown" };
    const containers = containersByApp.get(appName) || [];
    const workspace = await readTenantAppSpecSummary(tenantId, appName);
    const artifactEntries = artifacts.get(appName) || [];
    const latestArtifact = artifactEntries
      .slice()
      .sort((a, b) => {
        const aTs = Date.parse(a.data?.updatedAt || "") || 0;
        const bTs = Date.parse(b.data?.updatedAt || "") || 0;
        return bTs - aTs;
      })[0] || null;
    const issues = [];
    if (workspace.exists && workspace.hasLegacyPorts) {
      issues.push({
        code: "legacy_ports_in_spec",
        severity: "warn",
        message: "app.yml still contains a platform-managed ports binding",
      });
    }
    if (db && String(db.config.runtime || "") === "nerdctl" && !unit.present) {
      issues.push({
        code: "missing_unit",
        severity: "fail",
        message: "tenant service exists in DB but its user unit file is missing",
      });
    }
    if (db && String(db.config.runtime || "") === "nerdctl" && !route) {
      issues.push({
        code: "missing_caddy_route",
        severity: "fail",
        message: "tenant service exists in DB but has no Caddy route",
      });
    }
    if (!db && route) {
      issues.push({
        code: "stale_caddy_route",
        severity: "warn",
        message: "Caddy route exists with no matching tenant service",
      });
    }
    if (route && db && Number.isInteger(Number(db.config.port)) && Number(db.config.port) !== route.port) {
      issues.push({
        code: "db_caddy_port_mismatch",
        severity: "fail",
        message: `DB port ${db.config.port} does not match Caddy port ${route.port}`,
      });
    }
    if (!db && unit.present) {
      issues.push({
        code: "orphan_unit",
        severity: "warn",
        message: "user unit file exists with no matching tenant service",
      });
    }
    if (!db && artifactEntries.length > 0 && !unit.present && !workspace.exists && containers.length === 0 && !route) {
      issues.push({
        code: "stale_status",
        severity: "warn",
        message: "stale hosting-status artifact exists for a removed app",
      });
    }
    const dbPort = Number(db?.config?.port);
    if (Number.isInteger(dbPort) && (portOwners.get(dbPort)?.length || 0) > 1) {
      issues.push({
        code: "db_port_conflict",
        severity: "fail",
        message: `host port ${dbPort} is assigned to multiple tenant services`,
      });
    }

    const latestStatus = latestArtifact?.data || null;
    const effectiveStatus = deriveEffectiveAppStatus({
      appName,
      latestStatus,
      unit,
      containers,
      publicUrl: db?.subdomain ? `https://${db.subdomain}.${tenantId}.devitltd.uk` : (route?.publicUrl || null),
      subdomain: db?.subdomain || route?.subdomain || appName,
      hostPort: Number.isInteger(dbPort) ? dbPort : (route?.port || null),
    });

    apps.push({
      appName,
      subdomain: db?.subdomain || route?.subdomain || appName,
      publicUrl: db?.subdomain ? `https://${db.subdomain}.${tenantId}.devitltd.uk` : (route?.publicUrl || null),
      db: db ? { present: true, ...db } : { present: false },
      caddy: route ? { present: true, ...route } : { present: false },
      unit,
      containers,
      workspace,
      latestStatus,
      effectiveStatus,
      statusArtifacts: artifactEntries.map((entry) => ({ group: entry.group, path: entry.path })),
      issues,
    });
  }

  return {
    tenant,
    apps,
    summary: summarizeTenantAppIssues(apps),
    containers: ps,
  };
}

function renderTenantCaddyBlocksFromDb(tenantId, services, customDomains = []) {
  const appsRoot = path.join(TENANT_HOME_BASE, tenantId, "workspace", "apps");
  const blocks = [];
  for (const entry of services) {
    const subdomain = entry.subdomain;
    const svc = entry.config || {};
    const svcType = String(svc.type || "proxy");
    const directives = [];

    if (svc.basicAuth && typeof svc.basicAuth === "object") {
      const user = String(svc.basicAuth.user || "").replace(/[^a-zA-Z0-9_.-]/g, "");
      const hash = String(svc.basicAuth.passwordHash || "");
      if (user && /^\$2[aby]\$\d+\$.{53}$/.test(hash)) {
        directives.push(`  basic_auth {\n    ${user} ${hash}\n  }`);
      }
    }

    if (svcType === "static") {
      const resolved = path.resolve(appsRoot, String(svc.root || "."));
      directives.push(`  root * ${resolved}`);
      directives.push("  file_server");
    } else {
      const port = Number(svc.port);
      if (svc.websocket) {
        directives.push(`  @ws {\n    header Connection *Upgrade*\n    header Upgrade websocket\n  }`);
        directives.push(`  reverse_proxy @ws localhost:${port}`);
        directives.push(`  reverse_proxy localhost:${port}`);
      } else {
        directives.push(`  reverse_proxy localhost:${port}`);
      }
    }

    blocks.push(`${subdomain}.${tenantId}.devitltd.uk {\n${directives.join("\n")}\n}`);
    for (const customDomain of customDomains.filter((d) => d.target === subdomain).map((d) => d.domain)) {
      blocks.push(`${customDomain} {\n${directives.join("\n")}\n}`);
    }
  }
  return blocks;
}

async function regenerateTenantCaddyfileFromDb(tenantId) {
  const services = await getRawTenantServiceRows(tenantId);
  const customDomains = await dbQuery(
    "SELECT domain, target FROM custom_domains WHERE tenant_id = ? AND status = 'active' ORDER BY domain",
    [tenantId],
  );
  const blocks = renderTenantCaddyBlocksFromDb(tenantId, services, customDomains);
  const content =
    blocks.length > 0
      ? `# Tenant dashboard\n${tenantId}.devitltd.uk {\n  handle /hooks/* {\n    reverse_proxy localhost:${HOOKS_PORT} {\n      flush_interval -1\n      transport http {\n        read_timeout 0\n      }\n    }\n  }\n  handle {\n    reverse_proxy localhost:19100\n  }\n}\n\n${blocks.join("\n\n")}\n`
      : `# Tenant dashboard\n${tenantId}.devitltd.uk {\n  handle /hooks/* {\n    reverse_proxy localhost:${HOOKS_PORT} {\n      flush_interval -1\n      transport http {\n        read_timeout 0\n      }\n    }\n  }\n  handle {\n    reverse_proxy localhost:19100\n  }\n}\n`;
  const caddyPath = `${CADDY_TENANT_DIR}/${tenantId}.caddy`;
  const backup = await readTenantCaddyfile(tenantId);
  await writeFileFs(caddyPath, content);
  const validate = await sh("caddy", ["validate", "--config", "/etc/caddy/caddy_config", "--adapter", "caddyfile"]);
  if (!validate.ok) {
    if (backup != null) await writeFileFs(caddyPath, backup);
    else await rm(caddyPath, { force: true });
    throw new Error(validate.stderr || validate.stdout || "caddy validate failed");
  }
  const reload = await sh("systemctl", ["reload", "caddy"]);
  if (!reload.ok) {
    if (backup != null) await writeFileFs(caddyPath, backup);
    throw new Error(reload.stderr || reload.stdout || "systemctl reload caddy failed");
  }
}

async function getTenantMainIpcDir(tenantId) {
  const groups = await dbQuery(
    "SELECT folder FROM registered_groups WHERE tenant_id = ? AND is_main = 1 LIMIT 1",
    [tenantId],
  );
  if (!groups.length) {
    throw new Error("No main group registered for this tenant");
  }
  const folder = String(groups[0].folder || "");
  const prefix = `${tenantId}/`;
  const resolvedFolder = folder.startsWith(prefix) ? folder.slice(prefix.length) : folder;
  return path.join(TENANT_HOME_BASE, tenantId, "nanoclaw", "data", "ipc", resolvedFolder);
}

async function writeTenantAppIpcMessage(tenantId, suffix, payload) {
  const ipcBase = await getTenantMainIpcDir(tenantId);
  const messagesDir = path.join(ipcBase, "messages");
  await mkdir(messagesDir, { recursive: true });
  const filename = `${Date.now()}-${randomBytes(4).toString("hex")}-${suffix}.json`;
  await writeFileFs(path.join(messagesDir, filename), JSON.stringify(payload));
  return ipcBase;
}

// --- Helpers ---

async function sh(cmd, args = [], opts = {}) {
  try {
    const { stdout, stderr } = await exec(cmd, args, { timeout: 300000, ...opts });
    return { ok: true, stdout: stdout.trim(), stderr: stderr.trim() };
  } catch (e) {
    return { ok: false, stdout: e.stdout?.trim() || "", stderr: e.stderr?.trim() || e.message };
  }
}

async function dbQuery(sql, params = []) {
  const paramsJson = JSON.stringify(params);
  const script = `
    const Database = require('${NANOCLAW_BASE}/node_modules/better-sqlite3');
    const db = new Database('${DB_PATH}', { readonly: true });
    try {
      const rows = db.prepare(${JSON.stringify(sql)}).all(...${paramsJson});
      console.log(JSON.stringify(rows));
    } catch (e) { console.error(e.message); console.log('[]'); }
    db.close();
  `;
  const result = await sh("node", ["-e", script]);
  return result.ok ? JSON.parse(result.stdout) : [];
}

async function dbRun(sql, params = []) {
  const paramsJson = JSON.stringify(params);
  const script = `
    const Database = require('${NANOCLAW_BASE}/node_modules/better-sqlite3');
    const db = new Database('${DB_PATH}');
    try {
      const info = db.prepare(${JSON.stringify(sql)}).run(...${paramsJson});
      console.log(JSON.stringify({ ok: true, changes: info.changes }));
    } catch (e) { console.log(JSON.stringify({ ok: false, error: e.message })); }
    db.close();
  `;
  const result = await sh("node", ["-e", script]);
  if (!result.ok) return { ok: false, error: result.stderr };
  try {
    return JSON.parse(result.stdout);
  } catch {
    return { ok: false, error: "Failed to parse DB result" };
  }
}

async function getTenantSliceMemory(tenantId) {
  // Look up UID from /etc/passwd
  const passwd = await sh("id", ["-u", tenantId]);
  if (!passwd.ok) return null;
  const uid = passwd.stdout.trim();
  const mem = await sh("systemctl", ["show", `user-${uid}.slice`, "--property=MemoryCurrent"]);
  if (mem.ok && mem.stdout.includes("=")) {
    const bytes = parseInt(mem.stdout.split("=")[1]);
    return isNaN(bytes) || bytes === 0 ? null : Math.round(bytes / 1024 / 1024);
  }
  return null;
}

function parseDockerMemoryToMb(raw) {
  if (!raw) return null;
  const usage = raw.split("/")[0]?.trim();
  if (!usage || usage === "--") return null;
  const match = usage.match(/^([\d.]+)\s*([KMGTP]?I?B)?$/i);
  if (!match) return null;

  const value = parseFloat(match[1]);
  const unit = (match[2] || "B").toUpperCase();
  if (Number.isNaN(value)) return null;

  const multipliers = {
    B: 1 / (1024 * 1024),
    KB: 1 / 1000,
    KIB: 1 / 1024,
    MB: 1,
    MIB: 1,
    GB: 1000,
    GIB: 1024,
    TB: 1000 * 1000,
    TIB: 1024 * 1024,
    PB: 1000 * 1000 * 1000,
    PIB: 1024 * 1024 * 1024,
  };
  const multiplier = multipliers[unit];
  if (!multiplier) return null;
  return value * multiplier;
}

async function getTenantContainerStats(tenantId) {
  const tps = performance.now();
  const ps = await sh("docker", [
    "ps",
    "--filter", `name=nanoclaw-${tenantId}-`,
    "--filter", `name=${tenantId}-`,
    "--format", "{{.Names}}\t{{.Status}}",
  ]);
  console.log(`[docker ps:${tenantId}] ${Math.round(performance.now()-tps)}ms`);

  const containers = [];
  if (ps.ok && ps.stdout) {
    for (const line of ps.stdout.split("\n")) {
      const [name, status] = line.split("\t");
      if (!name) continue;
      const kind = name.startsWith("nanoclaw-") ? "agent" : "app";
      containers.push({ name, status, kind });
    }
  }

  let container_memory_mb = 0;
  if (containers.length > 0) {
    const tst = performance.now();
    const stats = await sh("docker", [
      "stats",
      "--no-stream",
      "--format", "{{.Name}}\t{{.MemUsage}}",
      ...containers.map((c) => c.name),
    ]);
    console.log(`[docker stats:${tenantId}] ${Math.round(performance.now()-tst)}ms containers=[${containers.map(c=>c.name).join(",")}]`);

    if (stats.ok && stats.stdout) {
      for (const line of stats.stdout.split("\n")) {
        const [name, memUsage] = line.split("\t");
        if (!name) continue;
        const memoryMb = parseDockerMemoryToMb(memUsage);
        const roundedMb = memoryMb == null ? null : Math.max(1, Math.round(memoryMb));
        const container = containers.find((c) => c.name === name);
        if (container) container.memory_mb = roundedMb;
        if (roundedMb != null) container_memory_mb += roundedMb;
      }
    }
  }

  return {
    containers,
    container_count: containers.length,
    container_memory_mb,
  };
}

async function getTenantResources(tenantId) {
  const t0 = performance.now();
  const containerStats = await getTenantContainerStats(tenantId);
  const t1 = performance.now();

  const volume = await getVolumeInfo(tenantId);
  const t2 = performance.now();

  const slice_mem_mb = await getTenantSliceMemory(tenantId);
  const t3 = performance.now();

  const memory_mb = slice_mem_mb ?? containerStats.container_memory_mb;

  console.log(`[getTenantResources:${tenantId}] containers=${Math.round(t1-t0)}ms volume=${Math.round(t2-t1)}ms slice=${Math.round(t3-t2)}ms`);

  return {
    ...containerStats,
    volume,
    slice_mem_mb,
    memory_mb,
  };
}

async function loadTenants() {
  const t0 = performance.now();
  const laps = [];
  function lap(label) {
    const now = performance.now();
    laps.push({ label, ms: Math.round(now - t0) });
    return now;
  }

  const tenants = await dbQuery("SELECT * FROM tenants ORDER BY created_at");
  lap("db:tenants");

  const today = new Date().toISOString().slice(0, 10);
  for (const t of tenants) {
    const ts = performance.now();
    t.groups = await dbQuery(
      "SELECT jid, name, folder, requires_trigger, is_main, hooks_token, hooks_enabled FROM registered_groups WHERE tenant_id = ?",
      [t.id]
    );
    laps.push({ label: `${t.id}:groups`, ms: Math.round(performance.now() - ts) });

    const tr = performance.now();
    t.resources = await getTenantResources(t.id);
    laps.push({ label: `${t.id}:resources`, ms: Math.round(performance.now() - tr) });

    // Daily usage stats
    const tu = performance.now();
    const usage = await dbQuery(
      `SELECT COUNT(*) as requests, COALESCE(SUM(duration_ms), 0) as total_duration_ms,
              COALESCE(SUM(input_tokens), 0) as total_input_tokens
       FROM usage_log WHERE tenant_id = ? AND timestamp >= ?`,
      [t.id, today]
    );
    const limits = await dbQuery(
      "SELECT daily_requests, daily_duration_ms FROM usage_limits WHERE tenant_id = ?",
      [t.id]
    );
    laps.push({ label: `${t.id}:usage+limits`, ms: Math.round(performance.now() - tu) });

    t.daily_usage = {
      requests: usage[0]?.requests || 0,
      duration_ms: usage[0]?.total_duration_ms || 0,
      input_tokens: usage[0]?.total_input_tokens || 0,
      limit_requests: limits[0]?.daily_requests || null,
      limit_duration_ms: limits[0]?.daily_duration_ms || null,
    };
  }

  const total = Math.round(performance.now() - t0);
  const rows = laps.map(l => `  ${l.label.padEnd(30)} ${String(l.ms).padStart(5)}ms`).join("\n");
  console.log(`[loadTenants] total=${total}ms\n${rows}`);

  return tenants;
}

async function getServiceStatus() {
  const status = await sh("systemctl", ["is-active", `${NANOCLAW_SERVICE}.service`]);
  return status.ok ? status.stdout : "stopped";
}

async function getServiceMemory() {
  const mem = await sh("systemctl", ["show", `${NANOCLAW_SERVICE}.service`, "--property=MemoryCurrent"]);
  if (mem.ok && mem.stdout.includes("=")) {
    const bytes = parseInt(mem.stdout.split("=")[1]);
    return isNaN(bytes) ? null : Math.round(bytes / 1024 / 1024);
  }
  return null;
}

async function getDatabaseStats() {
  const dbFiles = {
    main: DB_PATH,
    wal: `${DB_PATH}-wal`,
    shm: `${DB_PATH}-shm`,
  };

  const sizeBytes = {};
  for (const [key, filePath] of Object.entries(dbFiles)) {
    try {
      const info = await stat(filePath);
      sizeBytes[key] = info.size;
    } catch {
      sizeBytes[key] = 0;
    }
  }

  const pageInfo = await dbQuery(
    `SELECT
      (SELECT page_count FROM pragma_page_count) AS page_count,
      (SELECT page_size FROM pragma_page_size) AS page_size,
      (SELECT freelist_count FROM pragma_freelist_count) AS freelist_count`
  );
  const rowCounts = await dbQuery(
    `SELECT
      (SELECT COUNT(*) FROM messages) AS messages,
      (SELECT COUNT(*) FROM usage_log) AS usage_log,
      (SELECT COUNT(*) FROM chats) AS chats,
      (SELECT COUNT(*) FROM tenants) AS tenants`
  );

  return {
    file_mb: Math.round((sizeBytes.main || 0) / 1024 / 1024 * 10) / 10,
    wal_mb: Math.round((sizeBytes.wal || 0) / 1024 / 1024 * 10) / 10,
    shm_kb: Math.round((sizeBytes.shm || 0) / 1024),
    page_count: pageInfo[0]?.page_count || 0,
    page_size: pageInfo[0]?.page_size || 0,
    freelist_count: pageInfo[0]?.freelist_count || 0,
    messages: rowCounts[0]?.messages || 0,
    usage_log: rowCounts[0]?.usage_log || 0,
    chats: rowCounts[0]?.chats || 0,
    tenants: rowCounts[0]?.tenants || 0,
  };
}

async function getServerStats() {
  const cpuInfo = cpus();
  const memTotal = totalmem();
  const memFree = freemem();
  const memUsed = memTotal - memFree;

  const diskResult = await sh("df", ["-B1", "--output=size,used,avail", "/home"]);
  let disk = { total: 0, used: 0, free: 0 };
  if (diskResult.ok) {
    const lines = diskResult.stdout.split("\n");
    if (lines.length >= 2) {
      const parts = lines[1].trim().split(/\s+/);
      disk = { total: parseInt(parts[0]), used: parseInt(parts[1]), free: parseInt(parts[2]) };
    }
  }

  const loadResult = await sh("cat", ["/proc/loadavg"]);
  const loadAvg = loadResult.ok ? loadResult.stdout.split(" ").slice(0, 3).join(", ") : "N/A";
  const dockerResult = await sh("docker", ["images", "nanoclaw-agent", "--format", "{{.Tag}} ({{.Size}})"]);

  const tenants = await dbQuery("SELECT COUNT(*) as cnt FROM tenants");
  const tenantCount = tenants[0]?.cnt || 0;

  const serviceStatus = await getServiceStatus();
  const serviceMem = await getServiceMemory();
  const dbStats = await getDatabaseStats();

  return {
    hostname: hostname(),
    uptime_hours: Math.round(uptime() / 3600),
    cpu_cores: cpuInfo.length,
    cpu_model: cpuInfo[0]?.model || "unknown",
    load_avg: loadAvg,
    mem_total_gb: (memTotal / 1e9).toFixed(1),
    mem_used_gb: (memUsed / 1e9).toFixed(1),
    mem_free_gb: (memFree / 1e9).toFixed(1),
    mem_percent: Math.round((memUsed / memTotal) * 100),
    disk_total_gb: (disk.total / 1e9).toFixed(0),
    disk_used_gb: (disk.used / 1e9).toFixed(0),
    disk_free_gb: (disk.free / 1e9).toFixed(0),
    disk_percent: disk.total ? Math.round((disk.used / disk.total) * 100) : 0,
    container_image: dockerResult.ok ? dockerResult.stdout : "unknown",
    platform: "nanoclaw",
    tenant_count: tenantCount,
    service_status: serviceStatus,
    service_memory_mb: serviceMem,
    db: dbStats,
  };
}

// --- Workspace setup helpers ---

async function createTenantWorkspace(tenantId, folder) {
  const tenantHome = `${TENANT_HOME_BASE}/${tenantId}/nanoclaw`;
  const sharedWorkspace = `${TENANT_HOME_BASE}/${tenantId}/workspace`;
  const dirs = [
    `${tenantHome}/groups/${folder}`,
    `${tenantHome}/data/sessions/${folder}/.claude`,
    `${tenantHome}/data/ipc/${folder}`,
    sharedWorkspace,
    `${sharedWorkspace}/apps`,
    `${sharedWorkspace}/files`,
    `${sharedWorkspace}/scripts`,
  ];
  for (const dir of dirs) {
    await mkdir(dir, { recursive: true });
  }
  // Fix ownership on the entire data and groups trees (mkdir creates intermediates as root)
  await sh("chown", ["-R", `${tenantId}:nanoclaw`, `${tenantHome}/groups`]);
  await sh("chown", ["-R", `${tenantId}:nanoclaw`, `${tenantHome}/data`]);
  await sh("chown", ["-R", `${tenantId}:nanoclaw`, sharedWorkspace]);
  await sh("chmod", ["-R", "g+rwX", `${tenantHome}/groups`]);
  await sh("chmod", ["-R", "g+rwX", `${tenantHome}/data`]);
  await sh("chmod", ["2770", sharedWorkspace]);
  await sh("chmod", ["2770", `${sharedWorkspace}/apps`]);
  await sh("chmod", ["2770", `${sharedWorkspace}/files`]);
  await sh("chmod", ["2770", `${sharedWorkspace}/scripts`]);
  await sh("chmod", ["-R", "g+rwX", sharedWorkspace]);
  await sh("find", [sharedWorkspace, "-type", "d", "-exec", "chmod", "g+s", "{}", ";"]);
  // Re-lock immutable workspace files (recursive chown above may have undone previous locks)
  for (const name of ["AGENTS.md"]) {
    await sh("find", [`${tenantHome}/groups`, "-name", name, "-exec", "chown", "root:nanoclaw", "{}", ";"]);
    await sh("find", [`${tenantHome}/groups`, "-name", name, "-exec", "chmod", "444", "{}", ";"]);
  }
  // Write proxy.json if this tenant has tailscaleProxy enabled, so tc proxy works in new groups
  try {
    const nixText = await readFile(NIXOS_TENANTS_CONFIG, "utf8");
    const proxyEnabled = tenantId === "admin" || new RegExp(`^\\s*${tenantId}\\s*=\\s*\\{[^}]*tailscaleProxy\\s*=\\s*true`, "m").test(nixText);
    if (proxyEnabled) {
      const SOCKS5_PORT = 1055;
      const networkName = `nanoclaw-tenant-${tenantId}`;
      let proxyHost = "100.67.100.124";
      try {
        const { execFileSync: execF } = await import("child_process");
        const gw = execF("docker", ["network", "inspect", networkName, "--format", "{{range .IPAM.Config}}{{.Gateway}}{{end}}"], { encoding: "utf8", timeout: 5000 }).trim();
        if (gw) proxyHost = gw;
      } catch { /* network may not exist yet; fall back to Tailscale IP */ }
      const proxyFile = path.join(tenantHome, "data", "ipc", folder, "proxy.json");
      await writeFileFs(proxyFile, JSON.stringify({ url: `socks5://${proxyHost}:${SOCKS5_PORT}`, protocol: "socks5", host: proxyHost, port: SOCKS5_PORT }, null, 2));
    }
  } catch (e) {
    console.error(`[createTenantWorkspace] Failed to write proxy.json for ${tenantId}/${folder}:`, e.message);
  }
}

async function writeDefaultWorkspaceFiles(tenantId, folder, tenantName, { isAdmin = false } = {}) {
  const groupDir = `${TENANT_HOME_BASE}/${tenantId}/nanoclaw/groups/${folder}`;
  const agentsPath = `${groupDir}/AGENTS.md`;
  const soulPath = `${groupDir}/SOUL.md`;
  const identityPath = `${groupDir}/IDENTITY.md`;
  const userPath = `${groupDir}/USER.md`;
  const memoryPath = `${groupDir}/MEMORY.md`;
  const bootstrapPath = `${groupDir}/BOOTSTRAP.md`;
  const claudeCompatPath = `${groupDir}/CLAUDE.md`;

  let agents = `# ${tenantName}

You are a personal AI assistant for ${tenantName}.

## First Interaction

If BOOTSTRAP.md exists, treat onboarding as still pending. Welcome the user warmly and run a brief onboarding even if there has already been a system welcome message or other prior chat history:

1. Introduce yourself in a warm, upbeat way — you're their dedicated AI assistant, always here to help
2. Offer to be named — ask if they'd like to give you a name, or suggest a few
3. Ask about their personality preference — formal, casual, playful, etc.
4. Explain what you can do: write code, search the web, read/write files, run commands, help with projects, brainstorm, research, schedule recurring or one-off jobs, and more
5. Explain voice support clearly: you can receive voice recordings, transcribe speech to text in English only, and send voice replies with multilingual text-to-speech
6. Let them know you have memory — anything they ask you to remember, you will store in MEMORY.md so it persists across sessions
7. Above all, emphasise that you're here to be helpful, resourceful, and reliable

Keep the onboarding conversational, natural, and a little more friendly than formal. Format it cleanly for chat:

- Start with a short welcome line, with at most one light emoji
- Use short paragraphs with blank lines between them
- Use markdown bullets for capability highlights instead of a dense text block
- Ask one onboarding question at a time, not several stacked questions
- Do not output the onboarding as a numbered list or as one long wall of text

Once onboarding is complete:

- delete BOOTSTRAP.md so you do not repeat onboarding later
- remove this entire "## First Interaction" section from AGENTS.md so onboarding instructions are no longer present in the normal runtime instructions

## Guidelines

- Be helpful, concise, and friendly
- Remember context from previous conversations via MEMORY.md and files in memory/
- You can use tools to help with tasks: file I/O, web search, code execution
- Ask clarifying questions when the request is ambiguous
- You can schedule recurring or one-off agent jobs using the schedule_task tool — these run on a cron or interval and can post results here or to any group you have access to
- When the user asks you to remember something, write it to MEMORY.md or memory/
- When confirming memory, profile, or onboarding updates, describe the result plainly and do not mention workspace file names or paths unless the user explicitly asks
- Do not store durable memory in AGENTS.md

## Notes

(Add project-specific context, preferences, and instructions here)
`;

  if (isAdmin) {
    agents += `
## Admin Role

You are a **server administrator** for the Treehouse platform. The \`server-admin\` skill is loaded with full details — refer to it for server paths, commands, and rules.
`;
  }

  const identity = `# Identity

- Name: ${tenantName}
- Role: Personal AI assistant
`;

  const soul = `# Soul

- Tone: Helpful, concise, and friendly
- Style: Clear, practical, and supportive
`;

  const user = `# User

- Primary user: ${tenantName}
- Relationship: This agent exists to help ${tenantName}
`;

  const memory = `# Memory

Use this file for concise durable memory that should persist across sessions.
Keep it short and high-signal. Use files under memory/ for longer notes.
`;

  const bootstrap = `# Bootstrap
`;

  await writeFileFs(agentsPath, agents);
  await writeFileFs(soulPath, soul);
  await writeFileFs(identityPath, identity);
  await writeFileFs(userPath, user);
  await writeFileFs(memoryPath, memory);
  await writeFileFs(bootstrapPath, bootstrap);
  try {
    await unlinkFs(claudeCompatPath);
  } catch {}
  await symlinkFs("MEMORY.md", claudeCompatPath);

  for (const mutablePath of [agentsPath, soulPath, identityPath, userPath, memoryPath, bootstrapPath]) {
    await sh("chown", [`${tenantId}:nanoclaw`, mutablePath]);
    await sh("chmod", ["664", mutablePath]);
  }
}

// --- Full tenant provisioning pipeline ---

async function provisionTenant({ id, name, email, provider, discord_user_id, max_concurrent, max_memory_mb, cpu_quota, cpu_weight, max_disk_mb, guildId, is_admin, show_progress_messages }) {
  const guild = guildId || DEFAULT_GUILD_ID;
  const displayName = name || id;
  const steps = [];

  // 1. Create disk volume (must exist before nixos-rebuild tries to mount it)
  const diskMb = max_disk_mb || 4096;
  await createVolume(id, diskMb);
  steps.push(`volume: ${diskMb}MB`);

  // 2. NixOS user + mount + cgroup slice
  try {
    const uid = await addTenantToNixConfig(id, {
      maxMemoryMB: max_memory_mb || 4096,
      cpuQuota: cpu_quota || "200%",
      cpuWeight: cpu_weight || 100,
    });
    steps.push(`nix_config (uid=${uid})`);
  } catch (e) {
    if (!e.message.includes("already exists")) throw e;
    steps.push("nix_config (already existed)");
  }

  const rebuild = await nixosRebuildSwitch();
  if (!rebuild.ok) {
    try { await removeTenantFromNixConfig(id); } catch {}
    try { await deleteVolume(id); } catch {}
    throw new Error(`nixos-rebuild failed: ${rebuild.stderr}`);
  }
  steps.push("nixos_rebuild");

  // 2. Discord provisioning
  const bot = await getBotUser();
  const category = await createCategory(guild, displayName);
  steps.push(`discord_category: ${category.id}`);

  await lockCategoryPermissions(category.id, guild, bot.id, await getConciergesBotUserId());
  steps.push("discord_permissions");

  const generalChannel = await createTextChannel(guild, "general", category.id);
  steps.push(`discord_channel: ${generalChannel.id}`);

  // Grant user access if discord_user_id is provided. This should not block
  // tenant provisioning because onboarding can retry the category grant later.
  if (discord_user_id) {
    try {
      await grantUserCategoryAccess(category.id, discord_user_id);
      steps.push("discord_user_access");
    } catch (err) {
      console.warn(`[provision:${id}] failed to grant Discord category access to ${discord_user_id}: ${err.message}`);
      steps.push("discord_user_access_failed");
    }
  }

  const invite = await createInvite(generalChannel.id);
  const inviteUrl = `https://discord.gg/${invite.code}`;
  steps.push(`discord_invite: ${inviteUrl}`);

  // 3. Allocate port range
  const portRangeStart = await allocatePortRange();
  steps.push(`port_range: ${portRangeStart}-${portRangeStart + PORT_RANGE_SIZE - 1}`);

  // 4. DB tenant record
  const pairingCode = discord_user_id ? null : generatePairingCode();
  const result = await dbRun(
    `INSERT INTO tenants (id, name, email, provider, discord_user_id, discord_category_id, discord_invite, max_concurrent, max_memory_mb, cpu_quota, cpu_weight, max_disk_mb, port_range_start, port_range_size, dashboard_password, discord_pairing_code, is_admin, show_progress_messages, status, created_at)
     VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?)`,
    [id, displayName, email, provider || 'claude', discord_user_id || null, category.id, inviteUrl,
     max_concurrent || 100, max_memory_mb || 4096, cpu_quota || "200%", cpu_weight || 100, max_disk_mb || 4096,
     portRangeStart, PORT_RANGE_SIZE,
     hashPassword(generatePassword()), pairingCode, is_admin ? 1 : 0, show_progress_messages ? 1 : 0,
     new Date().toISOString()]
  );
  if (!result.ok) throw new Error(`DB insert failed: ${result.error}`);
  steps.push("db_tenant");

  // 4. Register #general as main group
  const compositeFolder = `${id}/general`;
  const jid = `dc:${generalChannel.id}`;

  await createTenantWorkspace(id, "general");
  await writeDefaultWorkspaceFiles(id, "general", displayName, { isAdmin: !!is_admin });
  steps.push("workspace");

  const groupResult = await dbRun(
    `INSERT OR REPLACE INTO registered_groups (jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger, is_main, tenant_id)
     VALUES (?, ?, ?, ?, ?, '{}', 0, 1, ?)`,
    [jid, "general", compositeFolder, "@trigger", new Date().toISOString(), id]
  );
  if (!groupResult.ok) throw new Error(`DB group insert failed: ${groupResult.error}`);
  steps.push("db_group");

  // 5. Write Caddy config for tenant subdomains
  await writeTenantCaddyfile(id, portRangeStart, PORT_RANGE_SIZE);
  steps.push("caddyfile");

  // 6. Signal nanoclaw to hot-reload new tenant + group
  await sh("systemctl", ["kill", "-s", "HUP", `${NANOCLAW_SERVICE}.service`]);
  steps.push("nanoclaw_reloaded");

  if (discord_user_id) {
    try {
      const conciergeToken = await getConciergeDiscordToken();
      await sendDiscordDm(
        discord_user_id,
        buildProvisioningInviteDm({ tenantName: displayName, inviteUrl }),
        conciergeToken,
      );
      steps.push("discord_invite_dm");
    } catch (err) {
      console.log(`[provision:${id}] failed to send Discord invite DM: ${err.message}`);
      steps.push("discord_invite_dm_failed");
    }
  }

  return {
    tenantId: id,
    categoryId: category.id,
    channelId: generalChannel.id,
    inviteUrl,
    pairingCode,
    portRange: `${portRangeStart}-${portRangeStart + PORT_RANGE_SIZE - 1}`,
    jid,
    steps,
  };
}

// --- Bot Tenant Provisioning ---

async function provisionBotTenant({ id, name, email, provider, discord_bot_token, max_concurrent, max_memory_mb, cpu_quota, cpu_weight, max_disk_mb, is_admin, show_progress_messages }) {
  const displayName = name || id;
  const steps = [];

  // 1. Create disk volume
  const diskMb = max_disk_mb || 4096;
  await createVolume(id, diskMb);
  steps.push(`volume: ${diskMb}MB`);

  // 2. NixOS user + mount + cgroup slice
  try {
    const uid = await addTenantToNixConfig(id, {
      maxMemoryMB: max_memory_mb || 4096,
      cpuQuota: cpu_quota || "200%",
      cpuWeight: cpu_weight || 100,
    });
    steps.push(`nix_config (uid=${uid})`);
  } catch (e) {
    if (!e.message.includes("already exists")) throw e;
    steps.push("nix_config (already existed)");
  }

  const rebuild = await nixosRebuildSwitch();
  if (!rebuild.ok) {
    try { await removeTenantFromNixConfig(id); } catch {}
    try { await deleteVolume(id); } catch {}
    throw new Error(`nixos-rebuild failed: ${rebuild.stderr}`);
  }
  steps.push("nixos_rebuild");

  // 3. Allocate port range
  const portRangeStart = await allocatePortRange();
  steps.push(`port_range: ${portRangeStart}-${portRangeStart + PORT_RANGE_SIZE - 1}`);

  // 4. DB tenant record (no Discord category/role — bot tenant manages their own)
  const result = await dbRun(
    `INSERT INTO tenants (id, name, email, provider, tenant_type, discord_bot_token, max_concurrent, max_memory_mb, cpu_quota, cpu_weight, max_disk_mb, port_range_start, port_range_size, dashboard_password, is_admin, show_progress_messages, status, created_at)
     VALUES (?, ?, ?, ?, 'bot', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?)`,
    [id, displayName, email, provider || 'claude', discord_bot_token,
     max_concurrent || 100, max_memory_mb || 4096, cpu_quota || "200%", cpu_weight || 100, max_disk_mb || 4096,
     portRangeStart, PORT_RANGE_SIZE,
     hashPassword(generatePassword()), is_admin ? 1 : 0, show_progress_messages ? 1 : 0,
     new Date().toISOString()]
  );
  if (!result.ok) throw new Error(`DB insert failed: ${result.error}`);
  steps.push("db_tenant (bot type)");

  // 5. Create workspace (no channel registration — tenant adds channels manually)
  await createTenantWorkspace(id, "general");
  await writeDefaultWorkspaceFiles(id, "general", displayName, { isAdmin: !!is_admin });
  steps.push("workspace");

  // 6. Write Caddy config for tenant subdomains
  await writeTenantCaddyfile(id, portRangeStart, PORT_RANGE_SIZE);
  steps.push("caddyfile");

  // 7. Signal nanoclaw to connect the new bot and reload
  await sh("systemctl", ["kill", "-s", "HUP", `${NANOCLAW_SERVICE}.service`]);
  steps.push("nanoclaw_reloaded");

  return { tenantId: id, steps };
}


// --- Task Management ---

async function ensureTasksSchema() {
  await dbRun(`CREATE TABLE IF NOT EXISTS mc_tasks (
    id TEXT PRIMARY KEY,
    title TEXT NOT NULL,
    description TEXT DEFAULT '',
    status TEXT NOT NULL DEFAULT 'backlog',
    priority TEXT NOT NULL DEFAULT 'normal',
    assigned_to TEXT DEFAULT '',
    labels TEXT DEFAULT '[]',
    comments TEXT DEFAULT '[]',
    created_at TEXT NOT NULL,
    updated_at TEXT NOT NULL
  )`);
}

async function ensureCredentialUsageSchema() {
  await dbRun(`CREATE TABLE IF NOT EXISTS credential_usage_snapshots (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    credential_id TEXT NOT NULL,
    provider TEXT NOT NULL,
    captured_at TEXT NOT NULL,
    status TEXT NOT NULL,
    http_status INTEGER,
    limits_json TEXT NOT NULL DEFAULT '{}',
    error TEXT
  )`);
  await dbRun(`CREATE INDEX IF NOT EXISTS idx_credential_usage_snapshots_cred_time
               ON credential_usage_snapshots (credential_id, captured_at)`);
}

async function probeClaudeUsageForTokens(tokens) {
  const results = [];
  for (const entry of tokens) {
    try {
      const isOAuth = entry.type === "oauth";
      const headers = {
        "anthropic-version": "2023-06-01",
        "content-type": "application/json",
      };
      if (isOAuth) {
        // OAuth: use Claude Code identity headers to enable Sonnet 4.6 + get Sonnet-specific rate limit bucket
        headers["Authorization"] = `Bearer ${entry.token}`;
        headers["anthropic-beta"] = "claude-code-20250219,oauth-2025-04-20,fine-grained-tool-streaming-2025-05-14";
        headers["anthropic-dangerous-direct-browser-access"] = "true";
        headers["x-app"] = "cli";
        headers["user-agent"] = "claude-cli/1.0.0";
      } else {
        headers["x-api-key"] = entry.token;
      }

      const body = {
        model: isOAuth ? "claude-sonnet-4-6" : "claude-haiku-4-5-20251001",
        max_tokens: 16,
        messages: [{ role: "user", content: "hi" }],
      };
      // OAuth tokens require Claude Code system prompt — without it Sonnet 4.6 returns 400
      if (isOAuth) {
        body.system = [{ type: "text", text: "You are Claude Code, Anthropic's official CLI for Claude." }];
      }

      const res = await fetch("https://api.anthropic.com/v1/messages", {
        method: "POST",
        headers,
        body: JSON.stringify(body),
        signal: AbortSignal.timeout(15000),
      });

      const limits = {};
      for (const [key, val] of res.headers.entries()) {
        if (key.startsWith("anthropic-ratelimit-")) {
          const shortKey = key.replace("anthropic-ratelimit-", "");
          limits[shortKey] = val;
        }
        if (key === "anthropic-organization-id") {
          limits.organization_id = val;
        }
      }

      results.push({
        id: entry.id,
        label: entry.label,
        type: entry.type,
        status: res.status === 200 ? "ok" : res.status === 429 ? "rate_limited" : "error",
        http_status: res.status,
        limits,
      });
    } catch (e) {
      results.push({
        id: entry.id,
        label: entry.label,
        type: entry.type,
        status: "error",
        error: e.message,
        limits: {},
      });
    }
  }
  return results;
}

async function snapshotAllCredentialUsage() {
  const capturedAt = new Date().toISOString();
  const snapshots = [];

  // Claude credentials (pool)
  const poolCreds = await dbQuery("SELECT id, provider, type, label, status, access_token, api_key, auth_json, pi_backend FROM credentials");
  const claudeTokens = [];
  const codexCreds = [];
  for (const c of poolCreds) {
    const provider = c.provider || "claude";
    if (provider === "claude") {
      if (c.type === "oauth" && c.access_token) {
        claudeTokens.push({ id: c.id, label: c.label || c.id, token: c.access_token, type: "oauth" });
      } else if (c.type === "apikey" && c.api_key) {
        claudeTokens.push({ id: c.id, label: c.label || c.id, token: c.api_key, type: "api_key" });
      }
    } else if (provider === "codex") {
      if (c.type === "oauth" && c.auth_json) {
        codexCreds.push({ id: c.id, label: c.label || c.id });
      }
    }
  }

  const claudeResults = await probeClaudeUsageForTokens(claudeTokens);
  for (const r of claudeResults) {
    snapshots.push({
      credential_id: r.id,
      provider: "claude",
      captured_at: capturedAt,
      status: r.status,
      http_status: r.http_status || null,
      limits_json: JSON.stringify(r.limits || {}),
      error: r.error || null,
    });
  }

  // Codex credentials: use treehouse CLI PTY driver to parse /status limits.
  // This avoids adding node-pty/xterm deps to MC itself.
  const treehouseBin = existsSync("/home/admin/bin/treehouse")
    ? "/home/admin/bin/treehouse"
    : "treehouse";
  for (const c of codexCreds) {
    try {
      const out = await sh(treehouseBin, ["creds", "usage", "--id", c.id], {
        timeout: 120000,
        env: {
          ...process.env,
          PATH: ["/run/current-system/sw/bin", "/home/admin/.npm-global/bin", process.env.PATH || ""].filter(Boolean).join(":"),
        },
      });
      const text = (out.ok ? out.stdout : out.stderr) || "";
      const getVal = (key) => {
        const line = text.split("\n").find((l) => l.trimStart().startsWith(key));
        if (!line) return null;
        const parts = line.trim().split(/\s+/);
        return parts.slice(1).join(" ").trim() || null;
      };
      const limits = {
        "5h_left_pct": getVal("5h_left_pct"),
        "5h_resets": getVal("5h_resets"),
        "weekly_left_pct": getVal("weekly_left_pct"),
        "weekly_resets": getVal("weekly_resets"),
      };
      snapshots.push({
        credential_id: c.id,
        provider: "codex",
        captured_at: capturedAt,
        status: out.ok ? "ok" : "error",
        http_status: null,
        limits_json: JSON.stringify(limits),
        error: out.ok ? null : (out.stderr || out.stdout || "codex usage probe failed").trim(),
      });
    } catch (e) {
      snapshots.push({
        credential_id: c.id,
        provider: "codex",
        captured_at: capturedAt,
        status: "error",
        http_status: null,
        limits_json: "{}",
        error: e.message,
      });
    }
  }

  // Pi credentials — probe per backend
  const piCreds = poolCreds.filter(c => (c.provider || "claude") === "pi");
  const piAnthropicTokens = [];
  const piCodexCreds = [];

  for (const c of piCreds) {
    const piBackend = c.pi_backend;
    let authJson = {};
    try { authJson = c.auth_json ? JSON.parse(c.auth_json) : {}; } catch {}

    if (piBackend === "anthropic") {
      const cred = authJson["anthropic"] || {};
      if (cred.access) {
        piAnthropicTokens.push({ id: c.id, label: c.label || c.id, token: cred.access, type: "oauth" });
      }
    } else if (piBackend === "openai-codex") {
      piCodexCreds.push({ id: c.id, label: c.label || c.id, auth_json: c.auth_json });
    } else {
      snapshots.push({
        credential_id: c.id,
        provider: "pi",
        captured_at: capturedAt,
        status: "not-supported",
        http_status: null,
        limits_json: JSON.stringify({ pi_backend: piBackend }),
        error: `No usage API available for backend: ${piBackend}`,
      });
    }
  }

  // Pi anthropic — same probe as Claude
  if (piAnthropicTokens.length > 0) {
    const piAnthropicResults = await probeClaudeUsageForTokens(piAnthropicTokens);
    for (const r of piAnthropicResults) {
      snapshots.push({
        credential_id: r.id,
        provider: "pi",
        captured_at: capturedAt,
        status: r.status,
        http_status: r.http_status || null,
        limits_json: JSON.stringify({ pi_backend: "anthropic", ...(r.limits || {}) }),
        error: r.error || null,
      });
    }
  }

  // Pi openai-codex — direct HTTP probe using pi's special headers
  for (const c of piCodexCreds) {
    try {
      let authJson = {};
      try { authJson = c.auth_json ? JSON.parse(c.auth_json) : {}; } catch {}
      const piCred = authJson["openai-codex"] || {};
      const accessToken = piCred.access;

      if (!accessToken) {
        snapshots.push({ credential_id: c.id, provider: "pi", captured_at: capturedAt,
          status: "error", http_status: null, limits_json: JSON.stringify({ pi_backend: "openai-codex" }),
          error: "No access token in auth_json" });
        continue;
      }

      // Decode JWT to get chatgpt_account_id
      let accountId = "";
      try {
        const payload = JSON.parse(Buffer.from(accessToken.split(".")[1], "base64url").toString());
        accountId = payload["https://api.openai.com/auth"]?.["chatgpt_account_id"] || "";
      } catch {}

      // Probe the codex API — pi's originator header bypasses Cloudflare
      const probeBody = JSON.stringify({
        model: "gpt-5.2", store: false, stream: true,
        instructions: "You are a helpful assistant.",
        input: [{ role: "user", content: [{ type: "input_text", text: "." }] }],
        text: { verbosity: "low" },
      });
      const res = await fetch("https://chatgpt.com/backend-api/codex/responses", {
        method: "POST",
        headers: {
          "Authorization": `Bearer ${accessToken}`,
          "chatgpt-account-id": accountId,
          "originator": "pi",
          "User-Agent": "pi (linux; Node.js)",
          "Content-Type": "application/json",
        },
        body: probeBody,
        signal: AbortSignal.timeout(20000),
      });

      const limits = { pi_backend: "openai-codex" };
      // Capture codex rate limit headers
      for (const [k, v] of res.headers.entries()) {
        if (k.startsWith("x-codex-")) limits[k.replace("x-codex-", "")] = v;
        if (k.startsWith("x-ratelimit-")) limits[k.replace("x-ratelimit-", "")] = v;
      }
      // Drain response body to free connection
      try { await res.body?.cancel(); } catch {}

      const isUsageLimitError = res.status === 429;
      snapshots.push({
        credential_id: c.id, provider: "pi", captured_at: capturedAt,
        status: res.ok ? "ok" : isUsageLimitError ? "rate_limited" : "error",
        http_status: res.status,
        limits_json: JSON.stringify(limits),
        error: res.ok ? null : `HTTP ${res.status}`,
      });
    } catch (e) {
      snapshots.push({ credential_id: c.id, provider: "pi", captured_at: capturedAt,
        status: "error", http_status: null, limits_json: JSON.stringify({ pi_backend: "openai-codex" }),
        error: e.message });
    }
  }

  // Persist
  for (const s of snapshots) {
    await dbRun(
      "INSERT INTO credential_usage_snapshots (credential_id, provider, captured_at, status, http_status, limits_json, error) VALUES (?, ?, ?, ?, ?, ?, ?)",
      [s.credential_id, s.provider, s.captured_at, s.status, s.http_status, s.limits_json, s.error],
    );
  }

  return { captured_at: capturedAt, count: snapshots.length };
}

function generateId() {
  return Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
}

function getContentType(filePath) {
  const ext = path.extname(filePath).toLowerCase();
  switch (ext) {
    case ".html": return "text/html; charset=utf-8";
    case ".js": return "text/javascript; charset=utf-8";
    case ".css": return "text/css; charset=utf-8";
    case ".json": return "application/json; charset=utf-8";
    case ".svg": return "image/svg+xml";
    case ".png": return "image/png";
    case ".jpg":
    case ".jpeg": return "image/jpeg";
    case ".webp": return "image/webp";
    case ".ico": return "image/x-icon";
    case ".txt": return "text/plain; charset=utf-8";
    case ".woff": return "font/woff";
    case ".woff2": return "font/woff2";
    default: return "application/octet-stream";
  }
}

async function serveMcUiAsset(req, res, url) {
  if (MC_UI_DEV_URL) {
    const { request: httpRequest } = await import("node:http");
    const target = new URL(url || "/", MC_UI_DEV_URL);
    const proxyReq = httpRequest(
      { hostname: target.hostname, port: target.port, path: target.pathname + target.search, method: req.method, headers: { ...req.headers, host: target.host } },
      (proxyRes) => {
        res.writeHead(proxyRes.statusCode, proxyRes.headers);
        proxyRes.pipe(res);
      }
    );
    proxyReq.on("error", () => { res.writeHead(502); res.end("Dev server unavailable"); });
    req.pipe(proxyReq);
    return;
  }

  const pathname = (url || "/").split("?")[0] || "/";
  const relativePath = pathname === "/" ? "index.html" : pathname.replace(/^\/+/, "");
  const resolvedPath = path.resolve(MC_UI_BUILD_DIR, relativePath);
  const buildRoot = path.resolve(MC_UI_BUILD_DIR);

  if (!resolvedPath.startsWith(buildRoot + path.sep) && resolvedPath !== buildRoot) {
    res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" });
    return res.end("Forbidden");
  }

  try {
    const info = await stat(resolvedPath);
    if (info.isFile()) {
      const content = await readFile(resolvedPath);
      const isImmutable = relativePath.includes("/_app/immutable/");
      const cacheHeader = isImmutable ? "public, max-age=31536000, immutable" : "no-cache";
      res.writeHead(200, { "Content-Type": getContentType(resolvedPath), "Cache-Control": cacheHeader });
      return res.end(content);
    }
  } catch {}

  try {
    const html = await readFile(path.join(MC_UI_BUILD_DIR, "index.html"), "utf8");
    res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-cache" });
    return res.end(html);
  } catch {
    res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
    return res.end("Not found");
  }
}

function parseStringArrayField(value) {
  if (Array.isArray(value)) {
    return value
      .map((item) => (typeof item === "string" ? item.trim() : ""))
      .filter(Boolean);
  }
  if (typeof value !== "string") {
    return [];
  }

  const trimmed = value.trim();
  if (!trimmed) {
    return [];
  }

  try {
    const parsed = JSON.parse(trimmed);
    if (Array.isArray(parsed)) {
      return parsed
        .map((item) => (typeof item === "string" ? item.trim() : ""))
        .filter(Boolean);
    }
  } catch {}

  // Legacy broken format like: [bug,discord]
  if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
    return trimmed
      .slice(1, -1)
      .split(",")
      .map((item) => item.trim().replace(/^['"]|['"]$/g, ""))
      .filter(Boolean);
  }

  return trimmed
    .split(",")
    .map((item) => item.trim().replace(/^['"]|['"]$/g, ""))
    .filter(Boolean);
}

function parseTaskCommentsField(value) {
  if (Array.isArray(value)) {
    return value;
  }
  if (typeof value !== "string") {
    return [];
  }

  const trimmed = value.trim();
  if (!trimmed) {
    return [];
  }

  try {
    const parsed = JSON.parse(trimmed);
    return Array.isArray(parsed) ? parsed : [];
  } catch {
    return [];
  }
}

function normalizeTaskRow(row) {
  return {
    ...row,
    labels: parseStringArrayField(row.labels),
    comments: parseTaskCommentsField(row.comments),
  };
}

// --- Tenant Dashboard Auth ---

async function ensureDashboardPasswordColumn() {
  await dbRun("ALTER TABLE tenants ADD COLUMN dashboard_password TEXT").catch(() => {});
  await dbRun("ALTER TABLE tenants ADD COLUMN port_range_start INTEGER").catch(() => {});
  await dbRun("ALTER TABLE tenants ADD COLUMN port_range_size INTEGER DEFAULT 100").catch(() => {});
  await dbRun("ALTER TABLE tenants ADD COLUMN show_progress_messages INTEGER DEFAULT 0").catch(() => {});

  // Backfill port ranges for existing tenants that don't have one
  const unassigned = await dbQuery("SELECT id FROM tenants WHERE port_range_start IS NULL ORDER BY created_at");
  for (let i = 0; i < unassigned.length; i++) {
    const portStart = PORT_RANGE_START + (i * PORT_RANGE_SIZE);
    await dbRun("UPDATE tenants SET port_range_start = ?, port_range_size = ? WHERE id = ? AND port_range_start IS NULL",
      [portStart, PORT_RANGE_SIZE, unassigned[i].id]);
  }
}

function hashPassword(password) {
  return createHash("sha256").update(password).digest("hex");
}

function generatePassword(length = 6) {
  const chars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789";
  let password = "";
  const bytes = randomBytes(length);
  for (let i = 0; i < length; i++) {
    password += chars[bytes[i] % chars.length];
  }
  return password;
}

function generatePairingCode() {
  // 4-char uppercase alphanumeric, avoiding ambiguous chars (0/O, 1/I/L)
  const chars = "ABCDEFGHJKMNPQRSTUVWXYZ23456789";
  let code = "";
  const bytes = randomBytes(4);
  for (let i = 0; i < 4; i++) {
    code += chars[bytes[i] % chars.length];
  }
  return code;
}

function checkBasicAuth(req) {
  const authHeader = req.headers["authorization"];
  if (!authHeader || !authHeader.startsWith("Basic ")) return null;
  const decoded = Buffer.from(authHeader.slice(6), "base64").toString();
  const colon = decoded.indexOf(":");
  if (colon === -1) return null;
  return { user: decoded.slice(0, colon), pass: decoded.slice(colon + 1) };
}

async function authenticateTenant(req, tenantId) {
  const creds = checkBasicAuth(req);
  if (!creds) return false;
  const rows = await dbQuery("SELECT dashboard_password FROM tenants WHERE id = ?", [tenantId]);
  if (rows.length === 0 || !rows[0].dashboard_password) return false;
  return hashPassword(creds.pass) === rows[0].dashboard_password;
}

function sendUnauthorized(res) {
  res.writeHead(401, {
    "Content-Type": "text/plain",
    "WWW-Authenticate": 'Basic realm="Tenant Dashboard"',
  });
  res.end("Unauthorized");
}

// --- API Routes ---

const routes = {
  "GET /api/pi-models": async () => {
    try {
      const { ModelRegistry, AuthStorage } = await import("/home/admin/treehouse/nanoclaw/container/agent-runner/node_modules/@mariozechner/pi-coding-agent/dist/index.js");
      const auth = AuthStorage.create("/tmp/pi-auth-mc.json");
      const registry = new ModelRegistry(auth);
      const all = registry.getAll();
      const byProvider = {};
      for (const m of all) {
        if (!byProvider[m.provider]) byProvider[m.provider] = [];
        byProvider[m.provider].push({ id: m.id, name: m.name || m.id });
      }
      // Friendly provider labels
      const providerLabels = {
        "anthropic": "Anthropic",
        "openai": "OpenAI",
        "openai-codex": "OpenAI Codex",
        "google": "Google",
        "google-vertex": "Google Vertex",
        "google-antigravity": "Google (Antigravity)",
        "google-gemini-cli": "Google Gemini CLI",
        "azure-openai-responses": "Azure OpenAI",
        "amazon-bedrock": "Amazon Bedrock",
        "groq": "Groq",
        "mistral": "Mistral",
        "openrouter": "OpenRouter",
        "vercel-ai-gateway": "Vercel AI Gateway",
        "github-copilot": "GitHub Copilot",
        "cerebras": "Cerebras",
        "xai": "xAI (Grok)",
        "huggingface": "HuggingFace",
        "opencode": "OpenCode",
        "opencode-go": "OpenCode Go",
        "zai": "ZAI",
        "minimax": "MiniMax",
        "minimax-cn": "MiniMax CN",
        "kimi-coding": "Kimi Coding",
      };
      const providers = Object.keys(byProvider).sort().map(id => ({
        id,
        label: providerLabels[id] || id,
        modelCount: byProvider[id].length,
      }));
      return { data: { providers, models: byProvider } };
    } catch (err) {
      return { error: `Failed to load pi models: ${err.message}`, status: 500 };
    }
  },

  "GET /api/settings": async () => {
    const rows = await dbQuery("SELECT key, value FROM router_state WHERE key IN ('default_provider', 'default_model', 'default_pi_backend', 'concierge_bot_user_id', 'concierge_tenant_id')");
    const settings = { default_provider: "claude", default_model: "claude-sonnet-4-6", default_pi_backend: "anthropic", concierge_bot_user_id: CONCIERGE_BOT_USER_ID || "", concierge_tenant_id: CONCIERGE_TENANT_ID || "" };
    // Seed concierge_tenant_id default from nanoclaw .env if not in env const
    if (!settings.concierge_tenant_id) {
      try {
        const env = await readFile(`${NANOCLAW_WORKDIR}/.env`, "utf8");
        const match = env.match(/CONCIERGE_TENANT_ID=(.*)/);
        if (match) settings.concierge_tenant_id = match[1].trim();
      } catch {}
    }
    for (const row of rows) {
      settings[row.key] = row.value;
    }
    return { data: settings };
  },

  "POST /api/settings": async (body) => {
    const { default_provider, default_model, default_pi_backend, concierge_bot_user_id, concierge_tenant_id } = body;
    let shouldHup = false;
    if (default_provider) {
      if (!["claude", "codex", "pi"].includes(default_provider)) return { error: "Invalid provider", status: 400 };
      await dbRun("INSERT OR REPLACE INTO router_state (key, value) VALUES ('default_provider', ?)", [default_provider]);
      shouldHup = true;
    }
    if (default_model) {
      await dbRun("INSERT OR REPLACE INTO router_state (key, value) VALUES ('default_model', ?)", [default_model]);
      shouldHup = true;
    }
    if (default_pi_backend) {
      // Accept any pi provider ID from the registry
      await dbRun("INSERT OR REPLACE INTO router_state (key, value) VALUES ('default_pi_backend', ?)", [default_pi_backend]);
      shouldHup = true;
    }
    if (concierge_bot_user_id !== undefined) {
      await dbRun("INSERT OR REPLACE INTO router_state (key, value) VALUES ('concierge_bot_user_id', ?)", [concierge_bot_user_id]);
    }
    if (concierge_tenant_id !== undefined) {
      await dbRun("INSERT OR REPLACE INTO router_state (key, value) VALUES ('concierge_tenant_id', ?)", [concierge_tenant_id]);
      _conciergeTenantId = null; // reset cache
      // Also write to nanoclaw .env so nanoclaw picks it up dynamically
      try {
        let env = await readFile(`${NANOCLAW_WORKDIR}/.env`, "utf8");
        if (env.match(/CONCIERGE_TENANT_ID=/m)) {
          env = env.replace(/CONCIERGE_TENANT_ID=.*/m, `CONCIERGE_TENANT_ID=${concierge_tenant_id}`);
        } else {
          env = env.trimEnd() + `\nCONCIERGE_TENANT_ID=${concierge_tenant_id}\n`;
        }
        await writeFileFs(`${NANOCLAW_WORKDIR}/.env`, env, "utf8");
      } catch {}
    }
    if (shouldHup) {
      await sh("systemctl", ["kill", "-s", "HUP", `${NANOCLAW_SERVICE}.service`]);
    }
    return { data: { message: "Settings updated" } };
  },

  "GET /api/stats": async () => ({ data: await getServerStats() }),

  "GET /api/apps/ps": async () => {
    const tenants = await getTenantsFromNixosTenantsNix();
    const results = [];
    for (const t of tenants) {
      const ps = await nerdctlPsAsTenant(t);
      results.push({ tenant: t.tenant, uid: t.uid, socket: containerdSocket(t.uid), ...ps });
    }
    return { data: results };
  },

  "GET /api/tenants/list": async () => ({ data: await getTenantsFromNixosTenantsNix() }),

  "GET /api/apps/list": async (_body, _params, query) => {
    const tenant = query.tenant || "";
    const tenants = await getTenantsFromNixosTenantsNix();
    const targetTenants = tenant ? tenants.filter((t) => t.tenant === tenant) : tenants;
    const rows = [];
    for (const t of targetTenants) {
      const apps = await listAppsOnDisk(t.tenant);
      if (apps.length === 0) {
        rows.push({ tenant: t.tenant, app: "", app_dir: `${TENANT_HOME_BASE}/${t.tenant}/workspace/apps`, note: "(none)" });
        continue;
      }
      for (const app of apps) {
        rows.push({ tenant: t.tenant, app, app_dir: `${TENANT_HOME_BASE}/${t.tenant}/workspace/apps/${app}`, note: "" });
      }
    }
    return { data: rows };
  },

  "GET /api/apps/status": async (_body, _params, query) => {
    const tenantId = query.tenant || "";
    if (!tenantId) return { error: "Missing tenant", status: 400 };
    const app = query.app || "";
    const inventory = await inspectTenantApps(tenantId, { app });
    if (!inventory.tenant) return { error: "Unknown tenant", status: 404 };
    return { data: inventory };
  },

  "POST /api/apps/deploy": async (body) => {
    const { tenant, app, forceRebuild } = body || {};
    if (!tenant || !app) return { error: "Missing tenant/app", status: 400 };
    const t = (await getTenantsFromNixosTenantsNix()).find((x) => x.tenant === tenant);
    if (!t) return { error: "Unknown tenant", status: 404 };
    try {
      await writeTenantAppIpcMessage(tenant, "app-apply", {
        type: "app-apply",
        app,
        force_rebuild: forceRebuild === true,
      });
      return { data: { ok: true } };
    } catch (error) {
      return { error: error?.message || "failed to write app-apply IPC message", status: 500 };
    }
  },

  "POST /api/apps/remove": async (body) => {
    const { tenant, app } = body || {};
    if (!tenant || !app) return { error: "Missing tenant/app", status: 400 };
    const t = (await getTenantsFromNixosTenantsNix()).find((x) => x.tenant === tenant);
    if (!t) return { error: "Unknown tenant", status: 404 };
    try {
      await writeTenantAppIpcMessage(tenant, "app-remove", {
        type: "app-remove",
        app,
      });
      return { data: { ok: true } };
    } catch (error) {
      return { error: error?.message || "failed to write app-remove IPC message", status: 500 };
    }
  },

  "POST /api/apps/reconcile": async (body) => {
    const { tenant, app, fix = false } = body || {};
    if (!tenant) return { error: "Missing tenant", status: 400 };
    const inventory = await inspectTenantApps(tenant, { app });
    if (!inventory.tenant) return { error: "Unknown tenant", status: 404 };

    const actions = [];
    if (fix) {
      let shouldRegenerateCaddy = false;
      for (const item of inventory.apps) {
        if (canAutoPruneMissingUnit(item)) {
          try {
            await dbRun(
              "DELETE FROM tenant_services WHERE tenant_id = ? AND subdomain = ?",
              [tenant, item.db.subdomain],
            );
            actions.push({
              type: "remove-db-service",
              app: item.appName,
              ok: true,
              subdomain: item.db.subdomain,
            });
            shouldRegenerateCaddy = true;
          } catch (error) {
            actions.push({
              type: "remove-db-service",
              app: item.appName,
              ok: false,
              subdomain: item.db?.subdomain || item.appName,
              error: error?.message || "db delete failed",
            });
          }
          for (const artifact of item.statusArtifacts) {
            try {
              await unlinkFs(artifact.path);
              actions.push({ type: "remove-status", app: item.appName, ok: true, path: artifact.path });
            } catch (error) {
              actions.push({ type: "remove-status", app: item.appName, ok: false, path: artifact.path, error: error?.message || "unlink failed" });
            }
          }
          continue;
        }
        if (item.issues.some((issue) => issue.code === "orphan_unit")) {
          const out = await sh("sudo", [TENANT_RUNTIME_HELPER, tenant, "rm-app-runtime", item.appName], { timeout: 10000 });
          actions.push({
            type: "rm-app-runtime",
            app: item.appName,
            ok: out.ok,
            output: out.ok ? out.stdout.trim() : out.stderr.trim(),
          });
        }
        if (item.issues.some((issue) => issue.code === "stale_status")) {
          for (const artifact of item.statusArtifacts) {
            try {
              await unlinkFs(artifact.path);
              actions.push({ type: "remove-status", app: item.appName, ok: true, path: artifact.path });
            } catch (error) {
              actions.push({ type: "remove-status", app: item.appName, ok: false, path: artifact.path, error: error?.message || "unlink failed" });
            }
          }
        }
        if (item.issues.some((issue) => ["missing_caddy_route", "stale_caddy_route", "db_caddy_port_mismatch"].includes(issue.code))) {
          shouldRegenerateCaddy = true;
        }
      }
      if (shouldRegenerateCaddy) {
        try {
          await regenerateTenantCaddyfileFromDb(tenant);
          actions.push({ type: "regenerate-caddy", ok: true });
        } catch (error) {
          actions.push({ type: "regenerate-caddy", ok: false, error: error?.message || "caddy regeneration failed" });
        }
      }
    }

    const finalInventory = fix ? await inspectTenantApps(tenant, { app }) : inventory;
    return { data: { ...finalInventory, actions } };
  },

  "POST /api/apps/restart": async (body) => {
    const { tenant, app } = body || {};
    if (!tenant || !app) return { error: "Missing tenant/app", status: 400 };
    const t = (await getTenantsFromNixosTenantsNix()).find((x) => x.tenant === tenant);
    if (!t) return { error: "Unknown tenant", status: 404 };
    const out = await systemctlUser(tenant, t.uid, ["restart", `nanoclaw-app-${app}.service`]);
    if (!out.ok) return { error: out.stderr || "restart failed", status: 500 };
    return { data: { ok: true } };
  },

  "POST /api/apps/auth": async (body) => {
    const { tenant, subdomain, user, password, remove } = body || {};
    if (!tenant || !subdomain) return { error: "Missing tenant or subdomain", status: 400 };
    if (!remove && (!user || !password)) return { error: "Missing user or password", status: 400 };
    const t = (await getTenantsFromNixosTenantsNix()).find((x) => x.tenant === tenant);
    if (!t) return { error: "Unknown tenant", status: 404 };

    // Find main group IPC folder
    const groups = await dbQuery(
      "SELECT folder FROM registered_groups WHERE tenant_id = ? AND is_main = 1 LIMIT 1",
      [tenant]
    );
    if (!groups.length) return { error: "No main group registered for this tenant", status: 400 };
    const mainFolder = groups[0].folder;
    const ipcBase = path.join(TENANT_HOME_BASE, tenant, "nanoclaw", "data", "ipc", mainFolder);
    const messagesDir = path.join(ipcBase, "messages");

    // Generate bcrypt hash via caddy
    let passwordHash = "";
    if (!remove) {
      const hashResult = await sh("caddy", ["hash-password", "--plaintext", password]);
      if (!hashResult.ok) return { error: "Failed to generate password hash", status: 500 };
      passwordHash = hashResult.stdout.trim();
    }

    // Clear stale result file
    const resultFile = path.join(ipcBase, "hosting-status", `app-auth-${subdomain}.json`);
    try { await unlinkFs(resultFile); } catch {}

    // Write IPC message
    const msg = remove
      ? { type: "app-auth", subdomain, remove: true }
      : { type: "app-auth", subdomain, user, passwordHash };
    await mkdir(messagesDir, { recursive: true });
    await writeFileFs(path.join(messagesDir, `${Date.now()}-app-auth.json`), JSON.stringify(msg));

    // Poll for result (up to 15s)
    for (let i = 0; i < 30; i++) {
      await new Promise((r) => setTimeout(r, 500));
      try {
        const result = JSON.parse(await readFile(resultFile, "utf-8"));
        if (!result.ok) return { error: result.error || "auth update failed", status: 500 };
        return { data: { ok: true, subdomain, action: result.action, user: result.user || null } };
      } catch {}
    }
    return { error: "Timed out waiting for auth update", status: 500 };
  },

  "GET /api/apps/logs": async (_body, _params, query) => {
    const tenantId = query.tenant || "";
    const name = query.name || "";
    const tail = parseInt(query.tail || "200");
    if (!tenantId || !name) return { error: "Missing tenant/name", status: 400 };
    const t = (await getTenantsFromNixosTenantsNix()).find((x) => x.tenant === tenantId);
    if (!t) return { error: "Unknown tenant", status: 404 };
    const logs = await nerdctlLogsAsTenant({ tenant: t.tenant, uid: t.uid, name, tail: Number.isFinite(tail) ? tail : 200 });
    if (!logs.ok) return { error: logs.error || "logs failed", status: 500 };
    return { data: { tenant: tenantId, name, output: logs.output, note: logs.note || "" } };
  },

  "GET /api/runtime/ping": async (_body, _params, query) => {
    const tenant = query.tenant || "";
    const tenants = await getTenantsFromNixosTenantsNix();
    const targetTenants = tenant ? tenants.filter((t) => t.tenant === tenant) : tenants;
    const results = [];
    for (const t of targetTenants) {
      const info = await nerdctlInfoAsTenant(t);
      results.push({ tenant: t.tenant, uid: t.uid, socket: containerdSocket(t.uid), ...info });
    }
    return { data: results };
  },

  "GET /api/runtime/df": async (_body, _params, query) => {
    const tenantId = query.tenant || "";
    if (!tenantId) return { error: "Missing tenant", status: 400 };
    const t = (await getTenantsFromNixosTenantsNix()).find((x) => x.tenant === tenantId);
    if (!t) return { error: "Unknown tenant", status: 404 };
    const df = await nerdctlDfAsTenant(t);
    if (!df.ok) return { error: df.error || "runtime df failed", status: 500 };
    return { data: { tenant: tenantId, output: df.output, note: df.note || "" } };
  },

  "GET /api/volumes": async () => {
    const tenants = await dbQuery("SELECT id, max_disk_mb FROM tenants ORDER BY id");
    const volumes = await Promise.all(tenants.map(async (t) => {
      const imgPath = `${VOLUME_DIR}/${t.id}.img`;
      const mountPoint = `${TENANT_HOME_BASE}/${t.id}`;
      let img_bytes = null;
      try { img_bytes = (await stat(imgPath)).size; } catch {}
      let used_mb = null, avail_mb = null;
      const df = await sh("df", ["-BM", "--output=used,avail", mountPoint]);
      if (df.ok) {
        const parts = df.stdout.split("\n")[1]?.trim().split(/\s+/);
        if (parts?.length >= 2) {
          used_mb = parseInt(parts[0]);
          avail_mb = parseInt(parts[1]);
        }
      }
      return { tenant_id: t.id, max_disk_mb: t.max_disk_mb, img_bytes, used_mb, avail_mb };
    }));
    return { data: volumes };
  },

  "POST /api/volumes/:tenantId/resize": async (body, params) => {
    const { size_mb } = body;
    if (!size_mb || typeof size_mb !== "number" || size_mb < 100) {
      return { error: "size_mb must be a number >= 100", status: 400 };
    }
    const rows = await dbQuery("SELECT id FROM tenants WHERE id = ?", [params.tenantId]);
    if (!rows[0]) return { error: "Tenant not found", status: 404 };
    const update = await dbRun("UPDATE tenants SET max_disk_mb = ? WHERE id = ?", [size_mb, params.tenantId]);
    if (!update.ok) return { error: update.error, status: 500 };
    try {
      await resizeVolume(params.tenantId, size_mb);
    } catch (e) {
      console.log(`[volumes:${params.tenantId}] resize error: ${e.message}`);
      return { error: `Volume resize failed: ${e.message}`, status: 500 };
    }
    return { data: { message: `Volume for '${params.tenantId}' resized to ${size_mb}MB` } };
  },

  "GET /api/tenants/ports": async () => {
    const rows = await dbQuery("SELECT id AS tenant_id, port_range_start, port_range_size FROM tenants ORDER BY id");
    return { data: rows };
  },

  "GET /api/tenants/usage": async (_body, _params, query) => {
    const date = query.date || null;
    const dateExpr = date ? `'${date}'` : "date('now')";
    const sql = `
      SELECT
        t.id AS tenant_id,
        COALESCE(u.runs, 0) AS runs,
        COALESCE(u.duration_ms, 0) AS duration_ms,
        COALESCE(u.input_tokens, 0) AS input_tokens,
        ul.daily_requests AS daily_requests,
        ul.daily_duration_ms AS daily_duration_ms
      FROM tenants t
      LEFT JOIN (
        SELECT tenant_id,
               COUNT(*) AS runs,
               COALESCE(SUM(duration_ms), 0) AS duration_ms,
               COALESCE(SUM(input_tokens), 0) AS input_tokens
        FROM usage_log
        WHERE date(timestamp) = ${dateExpr}
        GROUP BY tenant_id
      ) u ON u.tenant_id = t.id
      LEFT JOIN usage_limits ul ON ul.tenant_id = t.id
      ORDER BY t.id;
    `;
    return { data: await dbQuery(sql) };
  },

  "GET /api/caddy/ping": async (_body, _params, query) => {
    const url = query.url;
    if (!url) return { status: 400, error: "url required" };
    try {
      const res = await fetch(url, {
        method: "GET",
        signal: AbortSignal.timeout(8000),
        redirect: "follow",
        headers: { "User-Agent": "Treehouse-MC/1.0" },
      });
      return { data: { url, status: res.status, ok: res.status < 400 || res.status === 401 } };
    } catch (e) {
      return { data: { url, status: 0, ok: false, error: String(e.message || e) } };
    }
  },

  "GET /api/caddy/lint": async () => {
    const configPath = (await stat("/etc/caddy/caddy_config").then(() => "/etc/caddy/caddy_config").catch(() => null))
      || (await stat("/etc/caddy/Caddyfile").then(() => "/etc/caddy/Caddyfile").catch(() => null));
    if (!configPath) return { error: "Missing caddy config", status: 500 };
    const args = ["validate", "--config", configPath];
    if (configPath.endsWith("caddy_config")) args.push("--adapter", "caddyfile");
    const out = await sh("caddy", args);
    if (!out.ok) return { error: out.stderr || "caddy validate failed", status: 500 };
    return { data: { ok: true, stdout: out.stdout, stderr: out.stderr } };
  },

  "POST /api/caddy/reload": async () => {
    const out = await sh("systemctl", ["reload", "caddy.service"]);
    if (!out.ok) return { error: out.stderr || "failed to reload caddy", status: 500 };
    return { data: { ok: true } };
  },

  "POST /api/nanoclaw/hup": async () => {
    const out = await sh("systemctl", ["kill", "-s", "HUP", "nanoclaw.service"]);
    if (!out.ok) return { error: out.stderr || "failed to HUP nanoclaw", status: 500 };
    return { data: { ok: true } };
  },

  "GET /api/nanoclaw/status": async () => {
    const out = await sh("systemctl", ["status", "--no-pager", "nanoclaw.service"]);
    if (!out.ok) return { error: out.stderr || "nanoclaw status failed", status: 500 };
    return { data: { ok: true, output: [out.stdout, out.stderr].filter(Boolean).join("\n").trim() } };
  },

  "GET /api/nanoclaw/logs": async (_body, _params, query) => {
    const lines = Math.max(1, Math.min(5000, parseInt(query.lines || "200")));
    const out = await sh("journalctl", ["-u", "nanoclaw.service", "--no-pager", "-n", String(lines)]);
    if (!out.ok) return { error: out.stderr || "nanoclaw logs failed", status: 500 };
    return { data: { ok: true, output: [out.stdout, out.stderr].filter(Boolean).join("\n").trim() } };
  },

  "POST /api/nanoclaw/restart": async (body) => {
    const confirm = body?.confirm === true;
    if (!confirm) return { error: "Missing confirm=true", status: 400 };
    const out = await sh("systemctl", ["restart", "nanoclaw.service"]);
    if (!out.ok) return { error: out.stderr || "failed to restart nanoclaw", status: 500 };
    return { data: { ok: true } };
  },

  "GET /api/backups/status": async () => {
    const out = await sh("systemctl", ["status", "--no-pager", "borgmatic.timer"]);
    if (!out.ok) return { error: out.stderr || "borgmatic.timer status failed", status: 500 };
    return { data: { ok: true, output: [out.stdout, out.stderr].filter(Boolean).join("\n").trim() } };
  },

  "POST /api/backups/run": async () => {
    const out = await sh("borgmatic", ["create", "--verbosity", "1"]);
    if (!out.ok) return { error: out.stderr || "borgmatic create failed", status: 500 };
    return { data: { ok: true, output: [out.stdout, out.stderr].filter(Boolean).join("\n").trim() } };
  },

  "GET /api/doctor": async () => {
    const checks = [];
    function addCheck(name, label, status, message, remediation = null) {
      checks.push({ name, label, status, message, ...(remediation ? { remediation } : {}) });
    }

    // NixOS version (informational)
    const nixos = await sh("nixos-version");
    const nixosVersion = nixos.ok ? nixos.stdout.trim() : "unknown";

    // Core services
    for (const [name, label, svc] of [
      ["nanoclaw_service", "nanoclaw", "nanoclaw.service"],
      ["caddy_service", "caddy", "caddy.service"],
      ["borgmatic_timer", "borgmatic timer", "borgmatic.timer"],
    ]) {
      const s = await sh("systemctl", ["is-active", svc]);
      const active = (s.stdout || "").trim();
      if (active === "active") {
        addCheck(name, label, "ok", `${svc} active`);
      } else {
        addCheck(name, label, "fail", `${svc} is ${active || "unknown"}`,
          `sudo systemctl status ${svc}  →  sudo systemctl restart ${svc}`);
      }
    }

    // Caddy config validation
    const caddyLint = await routes["GET /api/caddy/lint"]();
    if (caddyLint.data?.ok) {
      addCheck("caddy_config", "caddy config", "ok", "config validates cleanly");
    } else {
      addCheck("caddy_config", "caddy config", "fail",
        caddyLint.error || "config invalid",
        "treehouse caddy lint  →  fix errors in /etc/caddy/");
    }

    // Backup recency (< 25h)
    const borgShow = await sh("systemctl", ["show", "borgmatic.service",
      "--property=ExecMainExitTimestamp,ExecMainExitTimestampMonotonic"]);
    let backupChecked = false;
    if (borgShow.ok) {
      const m = borgShow.stdout.match(/ExecMainExitTimestamp=(.+)/);
      const ts = m?.[1]?.trim();
      if (ts && ts !== "n/a" && !ts.startsWith("0")) {
        const lastRun = new Date(ts);
        const hoursAgo = (Date.now() - lastRun.getTime()) / 3_600_000;
        if (!isNaN(hoursAgo)) {
          backupChecked = true;
          const ago = `${Math.round(hoursAgo)}h ago`;
          if (hoursAgo < 25) {
            addCheck("backup_recency", "backup recency", "ok", `last run ${ago}`);
          } else {
            addCheck("backup_recency", "backup recency", "warn",
              `last run ${ago} (expected < 25h)`,
              "treehouse backups status  →  treehouse backups run");
          }
        }
      }
    }
    if (!backupChecked) {
      addCheck("backup_recency", "backup recency", "warn",
        "no completed backup run found",
        "treehouse backups status  →  treehouse backups run");
    }

    // Tenant volume usage
    const tenants = await dbQuery("SELECT id FROM tenants ORDER BY id");
    for (const t of tenants) {
      const mountPoint = `${TENANT_HOME_BASE}/${t.id}`;
      const df = await sh("df", ["-BM", "--output=used,avail", mountPoint]);
      if (!df.ok) {
        addCheck(`volume_${t.id}`, `${t.id} volume`, "fail",
          "cannot read volume",
          `sudo mount | grep ${t.id}  →  check /var/lib/tenant-volumes/${t.id}.img`);
        continue;
      }
      const parts = (df.stdout || "").split("\n")[1]?.trim().split(/\s+/);
      if (!parts || parts.length < 2) {
        addCheck(`volume_${t.id}`, `${t.id} volume`, "warn", "cannot parse df output");
        continue;
      }
      const used = parseInt(parts[0]);
      const avail = parseInt(parts[1]);
      const total = used + avail;
      const pct = total > 0 ? Math.round((used / total) * 100) : 0;
      const msg = `${pct}% used (${used}M / ${total}M)`;
      if (pct >= 95) {
        addCheck(`volume_${t.id}`, `${t.id} volume`, "fail", msg,
          `treehouse runtime df --tenant ${t.id}  →  prune images or increase quota`);
      } else if (pct >= 85) {
        addCheck(`volume_${t.id}`, `${t.id} volume`, "warn", msg,
          `treehouse runtime df --tenant ${t.id}`);
      } else {
        addCheck(`volume_${t.id}`, `${t.id} volume`, "ok", msg);
      }
    }

    // Tenant runtime health (containerd ping)
    const nixosTenants = await getTenantsFromNixosTenantsNix();
    for (const t of nixosTenants) {
      const info = await nerdctlInfoAsTenant(t);
      if (info.ok) {
        addCheck(`runtime_${t.tenant}`, `${t.tenant} runtime`, "ok", "containerd responding");
      } else {
        addCheck(`runtime_${t.tenant}`, `${t.tenant} runtime`, "fail",
          `containerd not responding: ${info.error || "unknown"}`,
          `sudo systemctl --user -M ${t.tenant}@ status nanoclaw-containerd.service`);
      }
    }

    // Buildkit restart churn
    for (const t of nixosTenants) {
      const buildkit = await systemctlUser(t.tenant, t.uid, [
        "show",
        "nanoclaw-buildkitd.service",
        "--property=NRestarts",
      ]);
      const match = (buildkit.stdout || "").match(/NRestarts=(\d+)/);
      const restartCount = match ? Number(match[1]) : 0;
      if (restartCount >= 10) {
        addCheck(
          `buildkit_restarts_${t.tenant}`,
          `${t.tenant} buildkit`,
          "warn",
          `restart count ${restartCount}`,
          `sudo -u ${t.tenant} XDG_RUNTIME_DIR=/run/user/${t.uid} DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/${t.uid}/bus systemctl --user status nanoclaw-buildkitd.service`,
        );
      } else {
        addCheck(`buildkit_restarts_${t.tenant}`, `${t.tenant} buildkit`, "ok", `restart count ${restartCount}`);
      }
    }

    // App state drift
    for (const t of nixosTenants) {
      const inventory = await inspectTenantApps(t.tenant);
      if (inventory.summary.fail > 0) {
        addCheck(
          `apps_drift_${t.tenant}`,
          `${t.tenant} apps`,
          "fail",
          `${inventory.summary.fail} failing drift check(s), ${inventory.summary.warn} warning(s)`,
          `treehouse apps reconcile --tenant ${t.tenant}`,
        );
      } else if (inventory.summary.warn > 0) {
        addCheck(
          `apps_drift_${t.tenant}`,
          `${t.tenant} apps`,
          "warn",
          `${inventory.summary.warn} warning drift check(s)`,
          `treehouse apps reconcile --tenant ${t.tenant}`,
        );
      } else {
        addCheck(`apps_drift_${t.tenant}`, `${t.tenant} apps`, "ok", "state reconciled");
      }
    }

    // Stuck agent containers (running > 30 min)
    const dockerPs = await sh("docker", ["ps", "--filter", "name=nanoclaw-",
      "--format", "{{.Names}}\t{{.Status}}"]);
    if (dockerPs.ok) {
      const stuck = [];
      for (const line of (dockerPs.stdout || "").split("\n").map(l => l.trim()).filter(Boolean)) {
        const [name, status] = line.split("\t");
        const hrs = status?.match(/Up (\d+) hour/);
        const mins = status?.match(/Up (\d+) minute/);
        if (hrs) {
          stuck.push(`${name} (${status})`);
        } else if (mins && parseInt(mins[1]) > 30) {
          stuck.push(`${name} (${status})`);
        }
      }
      if (stuck.length === 0) {
        addCheck("stuck_containers", "stuck containers", "ok", "no long-running agent containers");
      } else {
        addCheck("stuck_containers", "stuck containers", "warn",
          `${stuck.length} container(s) running >30m: ${stuck.join(", ")}`,
          "treehouse agents ps  →  treehouse agents kill --id <id> --yes");
      }
    } else {
      addCheck("stuck_containers", "stuck containers", "warn",
        "cannot query docker containers", "docker ps");
    }

    const summary = { ok: 0, warn: 0, fail: 0 };
    for (const c of checks) summary[c.status] = (summary[c.status] || 0) + 1;

    return { data: { nixos_version: nixosVersion, checks, summary } };
  },

  // --- Agent runner containers (host Docker) ---

  "GET /api/agents/containers": async (_body, _params, query) => {
    const tenant = query.tenant || "";
    const filters = ["--filter", "name=nanoclaw-"];
    if (tenant) filters.push("--filter", `name=nanoclaw-${tenant}-`);
    const out = await sh("docker", [
      "ps",
      ...filters,
      "--format",
      "{{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Status}}",
    ]);
    if (!out.ok) return { error: out.stderr || "docker ps failed", status: 500 };

    // Fetch agent_status for all chats in one query; key by folder (slashes → dashes)
    const chatStatuses = await dbQuery(
      `SELECT rg.folder, c.jid, c.agent_status
       FROM registered_groups rg
       JOIN chats c ON c.jid = rg.jid`,
    );
    const statusByFolder = new Map(
      chatStatuses.map((r) => [r.folder.replace(/\//g, "-"), r.agent_status || "IDLE"]),
    );

    const rows = [];
    const lines = (out.stdout || "").split("\n").map((l) => l.trim()).filter(Boolean);
    for (const line of lines) {
      const [id, name, image, status] = line.split("\t");
      const m = /^nanoclaw-([a-z0-9-]+)-/.exec(name || "");
      const tenantFromName = m ? m[1] : "";
      // Container name: nanoclaw-{safefolder}-{timestamp}; strip prefix + timestamp to get safefolder
      const safeFolder = (name || "").replace(/^nanoclaw-/, "").replace(/-\d+$/, "");
      const agentStatus = statusByFolder.get(safeFolder) ?? "UNKNOWN";
      rows.push({ id, name, image, status, tenant: tenantFromName, agent_status: agentStatus });
    }
    return { data: rows };
  },

  "POST /api/agents/containers/kill": async (body) => {
    const ids = Array.isArray(body?.ids) ? body.ids.filter((x) => typeof x === "string" && x.trim()) : [];
    const tenant = typeof body?.tenant === "string" ? body.tenant : "";
    const all = body?.all === true;
    const confirm = body?.confirm === true;
    if (!confirm) return { error: "Missing confirm=true", status: 400 };

    if (!all && !tenant && ids.length === 0) {
      return { error: "Provide ids[], tenant, or all=true", status: 400 };
    }

    // Resolve target IDs if tenant/all requested
    let targetIds = ids;
    if (all || tenant) {
      const list = await routes["GET /api/agents/containers"]({}, {}, tenant ? { tenant } : {});
      if (list.error) return list;
      targetIds = list.data.map((r) => r.id);
    }

    if (targetIds.length === 0) return { data: { message: "No containers to kill" } };

    const result = await sh("docker", ["kill", ...targetIds]);
    if (!result.ok) return { error: result.stderr || "docker kill failed", status: 500 };
    return { data: { message: `Killed ${targetIds.length} container(s)`, killed: targetIds } };
  },

  "GET /api/agents/containers/logs": async (_body, _params, query) => {
    const id = query.id || "";
    const tail = Math.max(1, Math.min(5000, parseInt(query.tail || "200")));
    if (!id) return { error: "Missing id", status: 400 };
    const out = await sh("docker", ["logs", "--tail", String(tail), id]);
    if (!out.ok) return { error: out.stderr || "docker logs failed", status: 500 };
    return { data: { id, output: [out.stdout, out.stderr].filter(Boolean).join("\n").trim() } };
  },

  // --- Message visibility (messages.db) ---

  "GET /api/chats": async (_body, _params, query) => {
    const tenant = query.tenant || "";
    const limit = Math.max(1, Math.min(500, parseInt(query.limit || "50")));
    const sql = tenant
      ? `SELECT jid, name, last_message_time, channel, is_group FROM chats WHERE jid IN (SELECT jid FROM registered_groups WHERE folder LIKE ?) ORDER BY last_message_time DESC LIMIT ?`
      : `SELECT jid, name, last_message_time, channel, is_group FROM chats ORDER BY last_message_time DESC LIMIT ?`;
    const paramsSql = tenant ? [`${tenant}/%`, limit] : [limit];
    const rows = await dbQuery(sql, paramsSql);
    return { data: rows };
  },

  "GET /api/messages": async (_body, _params, query) => {
    const tenant = query.tenant || "";
    const chatJid = query.chat_jid || "";
    const groupFolder = query.group_folder || "";
    const limit = Math.max(1, Math.min(500, parseInt(query.limit || "50")));

    if (!tenant && !chatJid && !groupFolder) return { error: "Provide tenant, chat_jid, or group_folder", status: 400 };

    let sql = `
      SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me, is_bot_message
      FROM messages
    `;
    const paramsSql = [];
    const where = [];
    if (chatJid) {
      where.push("chat_jid = ?");
      paramsSql.push(chatJid);
    } else if (groupFolder) {
      where.push("chat_jid IN (SELECT jid FROM registered_groups WHERE folder = ?)");
      paramsSql.push(groupFolder);
    } else if (tenant) {
      where.push("chat_jid IN (SELECT jid FROM registered_groups WHERE folder LIKE ?)");
      paramsSql.push(`${tenant}/%`);
    }
    sql += ` WHERE ${where.join(" AND ")} ORDER BY timestamp DESC LIMIT ?`;
    paramsSql.push(limit);

    const rows = await dbQuery(sql, paramsSql);
    return { data: rows };
  },

  "GET /api/memory": async () => {
    const totalMb = Math.round(totalmem() / 1024 / 1024);
    // Use MemFree (not MemAvailable) so used includes page cache, consistent with cgroup MemoryCurrent
    const meminfo = await readFile('/proc/meminfo', 'utf8');
    const memFreeKb = parseInt(meminfo.match(/^MemFree:\s+(\d+)/m)?.[1] ?? '0');
    const freeMb = Math.round(memFreeKb / 1024);
    const usedMb = totalMb - freeMb;

    // Nanoclaw service memory
    const svcMem = await getServiceMemory();

    // Per-tenant slice memory
    const tenants = await dbQuery("SELECT id, max_memory_mb FROM tenants ORDER BY id");
    const slices = [];
    let tenantTotal = 0;
    for (const t of tenants) {
      const resources = await getTenantResources(t.id);
      const current = resources.memory_mb || 0;
      tenantTotal += current;
      slices.push({
        id: t.id,
        current_mb: current,
        limit_mb: t.max_memory_mb,
        slice_mb: resources.slice_mem_mb || 0,
        container_mb: resources.container_memory_mb || 0,
      });
    }

    const systemMb = Math.max(0, usedMb - tenantTotal - (svcMem || 0));

    return { data: {
      host_total_mb: totalMb,
      host_used_mb: usedMb,
      host_free_mb: freeMb,
      system_mb: systemMb,
      nanoclaw_mb: svcMem || 0,
      slices,
    }};
  },

  "GET /api/disk": async () => {
    // Host disk totals
    const dfHost = await sh("df", ["-BM", "--output=size,used,avail", "/"]);
    let hostTotal = 0, hostUsed = 0, hostFree = 0;
    if (dfHost.ok) {
      const parts = dfHost.stdout.split("\n")[1].trim().split(/\s+/);
      hostTotal = parseInt(parts[0]);
      hostUsed = parseInt(parts[1]);
      hostFree = parseInt(parts[2]);
    }

    // Per-tenant volumes: allocated (apparent size) vs actual disk usage (sparse) vs used inside
    const tenants = await dbQuery("SELECT id, max_disk_mb FROM tenants ORDER BY id");
    const volumes = [];
    let totalAllocated = 0, totalActual = 0;
    for (const t of tenants) {
      const imgPath = `${VOLUME_DIR}/${t.id}.img`;
      // Apparent size (what the volume is sized to)
      const apparent = await sh("du", ["--apparent-size", "-BM", imgPath]);
      const allocatedMb = apparent.ok ? parseInt(apparent.stdout) : 0;
      // Actual disk blocks used (sparse file real usage)
      const actual = await sh("du", ["-BM", imgPath]);
      const actualMb = actual.ok ? parseInt(actual.stdout) : 0;
      // Used inside the mounted filesystem
      const vol = await getVolumeInfo(t.id);
      volumes.push({
        id: t.id,
        allocated_mb: allocatedMb,
        actual_mb: actualMb,
        used_mb: vol?.used_mb || 0,
        avail_mb: vol?.avail_mb || 0,
        limit_mb: t.max_disk_mb,
      });
      totalAllocated += allocatedMb;
      totalActual += actualMb;
    }

    const systemUsed = hostUsed - totalActual;

    return { data: {
      host_total_mb: hostTotal,
      host_used_mb: hostUsed,
      host_free_mb: hostFree,
      system_mb: systemUsed > 0 ? systemUsed : 0,
      tenant_actual_mb: totalActual,
      volumes,
    }};
  },
  "GET /api/usage": async (body, params, query) => {
    const days = parseInt(query.days) || 7;
    const tenantFilter = query.tenant || null;
    const since = new Date();
    since.setDate(since.getDate() - days);
    const sinceStr = since.toISOString().slice(0, 10);

    // Per-tenant daily aggregates
    const whereClause = tenantFilter
      ? `WHERE timestamp >= ? AND tenant_id = ?`
      : `WHERE timestamp >= ?`;
    const queryParams = tenantFilter ? [sinceStr, tenantFilter] : [sinceStr];

    const daily = await dbQuery(
      `SELECT tenant_id, date(timestamp) as day,
              COUNT(*) as requests,
              COALESCE(SUM(duration_ms), 0) as total_duration_ms,
              COALESCE(SUM(input_tokens), 0) as total_input_tokens,
              COALESCE(SUM(output_tokens), 0) as total_output_tokens,
              COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation,
              COALESCE(SUM(cache_read_tokens), 0) as total_cache_read
       FROM usage_log ${whereClause}
       GROUP BY tenant_id, date(timestamp)
       ORDER BY day DESC, tenant_id`,
      queryParams
    );

    // Per-tenant totals for the period
    const totals = await dbQuery(
      `SELECT tenant_id,
              COUNT(*) as requests,
              COALESCE(SUM(duration_ms), 0) as total_duration_ms,
              COALESCE(SUM(input_tokens), 0) as total_input_tokens,
              COALESCE(SUM(output_tokens), 0) as total_output_tokens,
              COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation,
              COALESCE(SUM(cache_read_tokens), 0) as total_cache_read
       FROM usage_log ${whereClause}
       GROUP BY tenant_id
       ORDER BY total_input_tokens DESC`,
      queryParams
    );

    // Per-channel breakdown (top channels)
    const channels = await dbQuery(
      `SELECT tenant_id, group_folder,
              COUNT(*) as requests,
              COALESCE(SUM(duration_ms), 0) as total_duration_ms,
              COALESCE(SUM(input_tokens), 0) as total_input_tokens,
              COALESCE(SUM(output_tokens), 0) as total_output_tokens
       FROM usage_log ${whereClause}
       GROUP BY tenant_id, group_folder
       ORDER BY requests DESC
       LIMIT 20`,
      queryParams
    );

    // Limits for context
    const limits = await dbQuery("SELECT * FROM usage_limits");

    return { data: { days, since: sinceStr, daily, totals, channels, limits } };
  },

  "GET /api/claude-usage": async () => {
    const tokens = [];

    // Pool credentials
    const poolCreds = await dbQuery("SELECT id, provider, type, label, status, access_token, api_key FROM credentials WHERE COALESCE(provider, 'claude') = 'claude'");
    for (const c of poolCreds) {
      if (c.type === "oauth" && c.access_token) {
        tokens.push({ id: c.id, label: c.label || c.id, token: c.access_token, type: "oauth" });
      } else if (c.type === "apikey" && c.api_key) {
        tokens.push({ id: c.id, label: c.label || c.id, token: c.api_key, type: "api_key" });
      }
    }

    // Per-tenant keys (not in pool)
    const tenantKeys = await dbQuery("SELECT id, name, anthropic_key FROM tenants WHERE COALESCE(provider, 'claude') = 'claude' AND anthropic_key IS NOT NULL AND anthropic_key != ''");
    for (const t of tenantKeys) {
      tokens.push({ id: t.id, label: `${t.name || t.id} (tenant key)`, token: t.anthropic_key, type: "api_key" });
    }

    // Probe each token for rate limit headers
    const results = await probeClaudeUsageForTokens(tokens);

    return { data: { tokens: results } };
  },

  "POST /api/credentials/usage-snapshot": async () => {
    const res = await snapshotAllCredentialUsage();
    return { data: { message: "Usage snapshot captured", ...res } };
  },

  "GET /api/credentials/usage-latest": async () => {
    const rows = await dbQuery(
      `SELECT s.credential_id, s.provider, s.captured_at, s.status, s.http_status, s.limits_json, s.error
       FROM credential_usage_snapshots s
       INNER JOIN (
         SELECT credential_id, MAX(captured_at) AS captured_at
         FROM credential_usage_snapshots
         GROUP BY credential_id
       ) latest
       ON latest.credential_id = s.credential_id AND latest.captured_at = s.captured_at
       ORDER BY s.credential_id`
    );
    const snapshots = rows.map((r) => {
      let limits = {};
      try { limits = r.limits_json ? JSON.parse(r.limits_json) : {}; } catch {}
      return {
        credential_id: r.credential_id,
        provider: r.provider,
        captured_at: r.captured_at,
        status: r.status,
        http_status: r.http_status,
        limits,
        error: r.error || null,
      };
    });
    return { data: { snapshots } };
  },

  
  "GET /api/skills": async () => {
    const baseSkillsDir = `${NANOCLAW_BASE}/container/skills`;
    const platformSkillsDir = `${NANOCLAW_BASE}/container/skills-platform`;
    const adminSkillsDir = `${NANOCLAW_BASE}/container/skills-admin`;
    const base = (await listSkillsFromDir(baseSkillsDir)).map(s => ({ ...s, source: "base" }));
    const platform = (await listSkillsFromDir(platformSkillsDir)).map(s => ({ ...s, source: "platform" }));
    const admin = (await listSkillsFromDir(adminSkillsDir)).map(s => ({ ...s, source: "admin" }));
    return { data: [...base, ...platform, ...admin].sort((a, b) => a.dir.localeCompare(b.dir)) };
  },

  "GET /api/skills/:skillId": async (_body, params, query) => {
    const { skillId } = params;
    const source = query.source;
    const dirs = {
      base: `${NANOCLAW_BASE}/container/skills`,
      platform: `${NANOCLAW_BASE}/container/skills-platform`,
      admin: `${NANOCLAW_BASE}/container/skills-admin`,
    };
    const searchDirs = source && dirs[source] ? [dirs[source]] : Object.values(dirs);
    for (const dir of searchDirs) {
      try {
        const skillContent = await readFile(`${dir}/${skillId}/SKILL.md`, "utf8");
        const parsed = parseSkillFrontmatter(skillContent);
        const src = Object.entries(dirs).find(([, d]) => d === dir)?.[0] || "base";
        return { data: { dir: skillId, source: src, ...parsed } };
      } catch {}
    }
    return { status: 404, error: "Skill not found" };
  },

  "GET /api/tasks": async () => {
    const rows = await dbQuery("SELECT * FROM mc_tasks ORDER BY created_at DESC");
    return { data: rows.map(normalizeTaskRow) };
  },

  "GET /api/tasks/:taskId": async (body, params) => {
    const { taskId } = params;
    const [row] = await dbQuery("SELECT * FROM mc_tasks WHERE id = ?", [taskId]);
    if (!row) return { error: "Task not found", status: 404 };
    return { data: normalizeTaskRow(row) };
  },

  "POST /api/tasks": async (body) => {
    const { title, description, status, priority, assigned_to, labels } = body;
    if (!title) return { error: "title is required", status: 400 };
    const id = generateId();
    const now = new Date().toISOString();
    await dbRun(
      "INSERT INTO mc_tasks (id, title, description, status, priority, assigned_to, labels, comments, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)",
      [id, title, description||'', status||'backlog', priority||'normal', assigned_to||'', JSON.stringify(labels||[]), '[]', now, now]
    );
    const [row] = await dbQuery("SELECT * FROM mc_tasks WHERE id = ?", [id]);
    return { data: normalizeTaskRow(row) };
  },

  "PUT /api/tasks/:taskId": async (body, params) => {
    const { taskId } = params;
    const [row] = await dbQuery("SELECT * FROM mc_tasks WHERE id = ?", [taskId]);
    if (!row) return { error: "Task not found", status: 404 };
    const updated = {
      title:       body.title       ?? row.title,
      description: body.description ?? row.description,
      status:      body.status      ?? row.status,
      priority:    body.priority    ?? row.priority,
      assigned_to: body.assigned_to !== undefined ? body.assigned_to : row.assigned_to,
      labels:      body.labels      !== undefined ? JSON.stringify(body.labels) : row.labels,
    };
    const now = new Date().toISOString();
    await dbRun(
      "UPDATE mc_tasks SET title=?, description=?, status=?, priority=?, assigned_to=?, labels=?, updated_at=? WHERE id=?",
      [updated.title, updated.description, updated.status, updated.priority, updated.assigned_to, updated.labels, now, taskId]
    );
    const [fresh] = await dbQuery("SELECT * FROM mc_tasks WHERE id = ?", [taskId]);
    return { data: normalizeTaskRow(fresh) };
  },

  "DELETE /api/tasks/:taskId": async (body, params) => {
    const { taskId } = params;
    await dbRun("DELETE FROM mc_tasks WHERE id = ?", [taskId]);
    return { data: { message: "Task deleted" } };
  },

  "POST /api/tasks/:taskId/comments": async (body, params) => {
    const { taskId } = params;
    const [row] = await dbQuery("SELECT * FROM mc_tasks WHERE id = ?", [taskId]);
    if (!row) return { error: "Task not found", status: 404 };
    const comments = parseTaskCommentsField(row.comments);
    const comment = { id: generateId(), author: body.author || 'admin', content: body.content, created_at: new Date().toISOString() };
    comments.push(comment);
    const now = new Date().toISOString();
    await dbRun("UPDATE mc_tasks SET comments=?, updated_at=? WHERE id=?", [JSON.stringify(comments), now, taskId]);
    const [fresh] = await dbQuery("SELECT * FROM mc_tasks WHERE id = ?", [taskId]);
    return { data: normalizeTaskRow(fresh) };
  },

// --- Custom domains ---

  "GET /api/custom-domains": async (body, params, query) => {
    const { tenant_id } = query;
    const rows = tenant_id
      ? await dbQuery("SELECT * FROM custom_domains WHERE tenant_id = ? ORDER BY created_at DESC", [tenant_id])
      : await dbQuery("SELECT * FROM custom_domains ORDER BY tenant_id, created_at DESC");
    return { data: rows };
  },

  "GET /api/custom-domains/:domainId": async (body, params) => {
    const rows = await dbQuery("SELECT * FROM custom_domains WHERE id = ?", [params.domainId]);
    if (rows.length === 0) return { error: "Domain not found", status: 404 };
    return { data: rows[0] };
  },

  "POST /api/custom-domains": async (body) => {
    const { tenant_id, domain, target } = body;
    if (!tenant_id) return { error: "tenant_id is required", status: 400 };
    if (!domain) return { error: "domain is required", status: 400 };
    const normalized = domain.toLowerCase().trim();
    // Blocklist check
    if (normalized.endsWith('.devitltd.uk') || normalized === 'devitltd.uk'
        || normalized === 'mission.null' || normalized.endsWith('.null')
        || normalized === 'localhost' || normalized.endsWith('.localhost')
        || /^\d+\.\d+\.\d+\.\d+$/.test(normalized)
        || !normalized.includes('.') || !/^[a-z0-9][a-z0-9.-]*[a-z0-9]$/.test(normalized)) {
      return { error: `Domain "${normalized}" is not allowed`, status: 400 };
    }
    // Uniqueness check
    const existing = await dbQuery("SELECT * FROM custom_domains WHERE domain = ?", [normalized]);
    if (existing.length > 0) {
      return { error: `Domain "${normalized}" is already registered`, status: 409 };
    }
    const id = generateId();
    const token = Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2);
    const now = new Date().toISOString();
    await dbRun(
      "INSERT INTO custom_domains (id, tenant_id, domain, target, verification_token, status, created_at) VALUES (?,?,?,?,?,'pending',?)",
      [id, tenant_id, normalized, target || 'dashboard', token, now]
    );
    return { data: { id, tenant_id, domain: normalized, target: target || 'dashboard', verification_token: token, status: "pending", created_at: now, verified_at: null } };
  },

  "POST /api/custom-domains/:domainId/activate": async (body, params) => {
    const rows = await dbQuery("SELECT * FROM custom_domains WHERE id = ?", [params.domainId]);
    if (rows.length === 0) return { error: "Domain not found", status: 404 };
    const now = new Date().toISOString();
    await dbRun("UPDATE custom_domains SET status='active', verified_at=? WHERE id=?", [now, params.domainId]);
    // HUP nanoclaw so it picks up the change and regenerates Caddyfiles
    await sh("sudo", ["systemctl", "kill", "-s", "HUP", "nanoclaw"]).catch(() => {});
    const [row] = await dbQuery("SELECT * FROM custom_domains WHERE id = ?", [params.domainId]);
    return { data: row };
  },

  "DELETE /api/custom-domains/:domainId": async (body, params) => {
    const rows = await dbQuery("SELECT * FROM custom_domains WHERE id = ?", [params.domainId]);
    if (rows.length === 0) return { error: "Domain not found", status: 404 };
    await dbRun("DELETE FROM custom_domains WHERE id = ?", [params.domainId]);
    // HUP nanoclaw so Caddyfile is regenerated without this domain
    await sh("sudo", ["systemctl", "kill", "-s", "HUP", "nanoclaw"]).catch(() => {});
    return { data: { message: "Domain deleted" } };
  },

"GET /api/tenants": async () => ({ data: await loadTenants() }),

  "GET /api/tenants/:tenantId": async (body, params) => {
    const { tenantId } = params;
    const rows = await dbQuery("SELECT * FROM tenants WHERE id = ?", [tenantId]);
    if (rows.length === 0) return { error: "Tenant not found", status: 404 };
    const t = rows[0];
    t.groups = await dbQuery(
      "SELECT jid, name, folder, requires_trigger, is_main, hooks_token, hooks_enabled FROM registered_groups WHERE tenant_id = ?",
      [tenantId]
    );
    t.resources = await getTenantResources(tenantId);
    const today = new Date().toISOString().slice(0, 10);
    const usage = await dbQuery(
      `SELECT COUNT(*) as requests, COALESCE(SUM(duration_ms), 0) as total_duration_ms,
              COALESCE(SUM(input_tokens), 0) as total_input_tokens
       FROM usage_log WHERE tenant_id = ? AND timestamp >= ?`,
      [tenantId, today]
    );
    const limits = await dbQuery(
      "SELECT daily_requests, daily_duration_ms FROM usage_limits WHERE tenant_id = ?",
      [tenantId]
    );
    t.daily_usage = {
      requests: usage[0]?.requests || 0,
      duration_ms: usage[0]?.total_duration_ms || 0,
      input_tokens: usage[0]?.total_input_tokens || 0,
      limit_requests: limits[0]?.daily_requests || null,
      limit_duration_ms: limits[0]?.daily_duration_ms || null,
    };
    return { data: t };
  },

  "POST /api/tenants": async (body) => {
    const { id, name, email, provider, discord_user_id, tenant_type, discord_bot_token, max_concurrent, max_memory_mb, cpu_quota, cpu_weight, max_disk_mb, provision_discord, is_admin, model, show_progress_messages } = body;
    if (!id) return { error: "id is required", status: 400 };
    if (!email) return { error: "email is required", status: 400 };
    if (provider !== undefined && !["claude", "codex", "pi"].includes(provider)) {
      return { error: "provider must be claude, codex, or pi", status: 400 };
    }
    if (!/^[a-z][a-z0-9-]{1,30}$/.test(id)) {
      return { error: "id must be lowercase, start with a letter, 2-31 chars", status: 400 };
    }
    if (max_disk_mb !== undefined && max_disk_mb < 100) {
      return { error: "Minimum volume size is 100 MB", status: 400 };
    }
    if (tenant_type === "bot" && !discord_bot_token) {
      return { error: "Bot tenants require a discord_bot_token", status: 400 };
    }

    const existing = await dbQuery("SELECT id FROM tenants WHERE id = ?", [id]);
    if (existing.length > 0) {
      return { error: `Tenant '${id}' already exists`, status: 409 };
    }

    // Bot tenant provisioning (own Discord bot identity)
    if (tenant_type === "bot") {
      try {
        const result = await provisionBotTenant({ id, name, email, provider, discord_bot_token, max_concurrent, max_memory_mb, cpu_quota, cpu_weight, max_disk_mb, is_admin, show_progress_messages });
        if (model !== undefined) {
          await dbRun("UPDATE tenants SET model = ? WHERE id = ?", [model || null, id]);
        }
        return { data: {
          message: `Bot tenant '${id}' provisioned — register channels via the API or dashboard`,
          steps: result.steps,
        }};
      } catch (e) {
        return { error: `Provisioning failed: ${e.message}`, status: 500 };
      }
    }

    // Full provisioning with Discord (default for standard tenants)
    if (provision_discord !== false) {
      try {
        const result = await provisionTenant({ id, name, email, provider, discord_user_id, max_concurrent, max_memory_mb, cpu_quota, cpu_weight, max_disk_mb, is_admin, show_progress_messages });
        if (model !== undefined) {
          await dbRun("UPDATE tenants SET model = ? WHERE id = ?", [model || null, id]);
        }
        return { data: {
          message: `Tenant '${id}' fully provisioned`,
          inviteUrl: result.inviteUrl,
          pairingCode: result.pairingCode,
          channelId: result.channelId,
          categoryId: result.categoryId,
          steps: result.steps,
        }};
      } catch (e) {
        return { error: `Provisioning failed: ${e.message}`, status: 500 };
      }
    }

    // Manual provisioning (no Discord)
    try {
      await createVolume(id, max_disk_mb || 4096);
    } catch (e) {
      return { error: `Volume creation failed: ${e.message}`, status: 500 };
    }

    try {
      await addTenantToNixConfig(id, {
        maxMemoryMB: max_memory_mb || 4096,
        cpuQuota: cpu_quota || "200%",
        cpuWeight: cpu_weight || 100,
      });
    } catch (e) {
      if (!e.message.includes("already exists")) return { error: e.message, status: 409 };
    }

    const rebuild = await nixosRebuildSwitch();
    if (!rebuild.ok) {
      try { await removeTenantFromNixConfig(id); } catch {}
      try { await deleteVolume(id); } catch {}
      return { error: `nixos-rebuild failed: ${rebuild.stderr}`, status: 500 };
    }

    const portRangeStart = await allocatePortRange();
    const result = await dbRun(
      `INSERT INTO tenants (id, name, email, provider, max_concurrent, max_memory_mb, cpu_quota, cpu_weight, max_disk_mb, port_range_start, port_range_size, dashboard_password, is_admin, model, show_progress_messages, status, created_at)
       VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?)`,
      [id, name || id, email, provider || 'claude', max_concurrent || 100, max_memory_mb || 4096, cpu_quota || "200%", cpu_weight || 100, max_disk_mb || 4096, portRangeStart, PORT_RANGE_SIZE, hashPassword(generatePassword()), is_admin ? 1 : 0, model || null, show_progress_messages ? 1 : 0, new Date().toISOString()]
    );

    if (!result.ok) return { error: result.error, status: 500 };
    await writeTenantCaddyfile(id, portRangeStart, PORT_RANGE_SIZE);
    await sh("systemctl", ["kill", "-s", "HUP", `${NANOCLAW_SERVICE}.service`]);
    return { data: { message: `Tenant '${id}' provisioned (user + DB, no Discord)` } };
  },

  "PUT /api/tenants/:tenantId": async (body, params) => {
    const { name, email, provider, pi_backend, discord_user_id, tenant_type, discord_bot_token, max_concurrent, max_memory_mb, cpu_quota, cpu_weight, max_disk_mb, status, daily_duration_mins, daily_requests, credential_id, is_admin, model, show_progress_messages } = body;
    if (max_disk_mb !== undefined && max_disk_mb < 100) {
      return { error: "Minimum volume size is 100 MB", status: 400 };
    }
    if (provider !== undefined && provider !== null && !["claude", "codex", "pi"].includes(provider)) {
      return { error: "provider must be claude, codex, or pi", status: 400 };
    }
    const existingRows = await dbQuery(
      "SELECT max_memory_mb, cpu_quota, cpu_weight FROM tenants WHERE id = ?",
      [params.tenantId]
    );
    const existingTenant = existingRows[0];
    if (!existingTenant) {
      return { error: "Tenant not found", status: 404 };
    }
    const fields = [];
    const values = [];

    if (name !== undefined) { fields.push("name = ?"); values.push(name); }
    if (email !== undefined) { fields.push("email = ?"); values.push(email); }
    if (provider !== undefined) { fields.push("provider = ?"); values.push(provider); }
    if (discord_user_id !== undefined) { fields.push("discord_user_id = ?"); values.push(discord_user_id || null); }
    if (tenant_type !== undefined) { fields.push("tenant_type = ?"); values.push(tenant_type); }
    if (discord_bot_token !== undefined) { fields.push("discord_bot_token = ?"); values.push(discord_bot_token); }
    if (max_concurrent !== undefined) { fields.push("max_concurrent = ?"); values.push(max_concurrent); }
    if (max_memory_mb !== undefined) { fields.push("max_memory_mb = ?"); values.push(max_memory_mb); }
    if (cpu_quota !== undefined) { fields.push("cpu_quota = ?"); values.push(cpu_quota); }
    if (cpu_weight !== undefined) { fields.push("cpu_weight = ?"); values.push(cpu_weight); }
    if (max_disk_mb !== undefined) { fields.push("max_disk_mb = ?"); values.push(max_disk_mb); }
    if (status !== undefined) { fields.push("status = ?"); values.push(status); }
    if (credential_id !== undefined) { fields.push("credential_id = ?"); values.push(credential_id || null); }
    if (is_admin !== undefined) { fields.push("is_admin = ?"); values.push(is_admin ? 1 : 0); }
    if (model !== undefined) { fields.push("model = ?"); values.push(model || null); }
    if (pi_backend !== undefined) { fields.push("pi_backend = ?"); values.push(pi_backend || null); }
    if (show_progress_messages !== undefined) { fields.push("show_progress_messages = ?"); values.push(show_progress_messages ? 1 : 0); }

    if (fields.length === 0) return { error: "No fields to update", status: 400 };

    values.push(params.tenantId);
    const result = await dbRun(`UPDATE tenants SET ${fields.join(", ")} WHERE id = ?`, values);
    if (!result.ok) return { error: result.error, status: 500 };

    // Update cgroup limits if memory or CPU changed
    if (max_memory_mb !== undefined || cpu_quota !== undefined || cpu_weight !== undefined) {
      const cgroupChanged =
        (max_memory_mb !== undefined && Number(max_memory_mb) !== Number(existingTenant.max_memory_mb)) ||
        (cpu_quota !== undefined && String(cpu_quota) !== String(existingTenant.cpu_quota)) ||
        (cpu_weight !== undefined && Number(cpu_weight) !== Number(existingTenant.cpu_weight));

      if (cgroupChanged) {
        try {
          await updateTenantNixConfig(params.tenantId, {
            maxMemoryMB: max_memory_mb,
            cpuQuota: cpu_quota,
            cpuWeight: cpu_weight,
          });
          const rebuild = await nixosRebuildSwitch();
          if (!rebuild.ok) {
            console.log(`[PUT] nixos-rebuild failed for cgroup update: ${rebuild.stderr}`);
          }
        } catch (e) {
          console.log(`[PUT] cgroup update error: ${e.message}`);
        }
      }
    }

    // Update usage limits if changed
    if (daily_duration_mins !== undefined || daily_requests !== undefined) {
      const durationMs = daily_duration_mins ? daily_duration_mins * 60000 : null;
      await dbRun(
        `INSERT INTO usage_limits (tenant_id, daily_requests, daily_duration_ms)
         VALUES (?, ?, ?)
         ON CONFLICT(tenant_id) DO UPDATE SET daily_requests = ?, daily_duration_ms = ?`,
        [params.tenantId, daily_requests, durationMs, daily_requests, durationMs]
      );
      await sh("systemctl", ["kill", "-s", "HUP", `${NANOCLAW_SERVICE}.service`]);
    }

    // Grant Discord category access when discord_user_id is set
    if (discord_user_id) {
      const t = (await dbQuery("SELECT discord_category_id FROM tenants WHERE id = ?", [params.tenantId]))[0];
      if (t?.discord_category_id) {
        try {
          await grantUserCategoryAccess(t.discord_category_id, discord_user_id);
        } catch (e) {
          console.log(`[PUT] Discord access grant error: ${e.message}`);
        }
      }
    }

    // Resize volume if disk limit changed
    if (max_disk_mb !== undefined) {
      console.log(`[PUT] Resizing volume for ${params.tenantId} to ${max_disk_mb}MB`);
      try {
        await resizeVolume(params.tenantId, max_disk_mb);
      } catch (e) {
        console.log(`[PUT] Volume resize error: ${e.message}`);
        return { data: { message: `Tenant '${params.tenantId}' updated (volume resize failed: ${e.message})` } };
      }
    }

    // Tenant config is cached in nanoclaw memory. Any DB-backed tenant edit
    // should hot-reload via SIGHUP so provider/model/credentials/status changes
    // take effect on the next run without a full service restart.
    await sh("systemctl", ["kill", "-s", "HUP", `${NANOCLAW_SERVICE}.service`]);

    return { data: { message: `Tenant '${params.tenantId}' updated` } };
  },

  "DELETE /api/tenants/:tenantId": async (body, params) => {
    const tenantId = params.tenantId;

    // Look up tenant's Discord resources before deleting from DB
    const tenants = await dbQuery("SELECT discord_category_id, discord_role_id, discord_user_id FROM tenants WHERE id = ?", [tenantId]);
    const tenant = tenants[0];

    // 1. Clean up Discord resources
    if (tenant?.discord_category_id) {
      try {
        const channels = await getCategoryChannels(DEFAULT_GUILD_ID, tenant.discord_category_id);
        for (const ch of channels) {
          await deleteDiscordChannel(ch.id);
        }
        await deleteDiscordChannel(tenant.discord_category_id);
      } catch (e) {
        console.error(`Discord cleanup for ${tenantId}: ${e.message}`);
      }
    }
    // Clean up legacy role if it exists
    if (tenant?.discord_role_id) {
      try {
        await deleteRole(DEFAULT_GUILD_ID, tenant.discord_role_id);
      } catch (e) { /* role may not exist */ }
    }
    // Kick the tenant's Discord user from the guild
    if (tenant?.discord_user_id && DEFAULT_GUILD_ID) {
      try {
        await discordApi("DELETE", `/guilds/${DEFAULT_GUILD_ID}/members/${tenant.discord_user_id}`);
        console.log(`Kicked Discord user ${tenant.discord_user_id} from guild`);
      } catch (e) {
        console.log(`Discord kick for ${tenantId}: ${e.message}`);
      }
    }

    // 2. Remove from DB (tenant's own data + concierge groups for this tenant)
    const tables = ["registered_groups", "sessions", "scheduled_tasks", "chats", "messages", "usage_log", "usage_limits", "projects"];
    for (const table of tables) {
      await dbRun(`DELETE FROM ${table} WHERE tenant_id = ?`, [tenantId]);
    }
    // Clean up concierge groups registered for this tenant (e.g. gaia/user-{tenantId})
    const conciergeFolder = `%/user-${tenantId}`;
    for (const table of ["registered_groups", "sessions", "scheduled_tasks", "chats", "messages"]) {
      await dbRun(`DELETE FROM ${table} WHERE folder LIKE ?`, [conciergeFolder]);
    }
    const result = await dbRun("DELETE FROM tenants WHERE id = ?", [tenantId]);
    if (!result.ok) return { error: result.error, status: 500 };

    // 3. Remove from NixOS config and rebuild (removes mount unit)
    try {
      await removeTenantFromNixConfig(tenantId);
      await nixosRebuildSwitch();
    } catch (e) {
      return { data: { message: `Tenant '${tenantId}' deleted from DB + Discord (NixOS cleanup: ${e.message})` } };
    }

    // 4. Delete disk volume
    try {
      await deleteVolume(tenantId);
    } catch (e) {
      console.error(`Volume cleanup for ${tenantId}: ${e.message}`);
    }

    // 4.5 Remove leftover home directory (NixOS tmpfiles creates but never deletes)
    try {
      await sh("rm", ["-rf", `${TENANT_HOME_BASE}/${tenantId}`]);
    } catch (e) {
      console.error(`Home dir cleanup for ${tenantId}: ${e.message}`);
    }

    // 4.6 Clean up Caddy config and any docker-compose services
    try {
      const composePath = `${TENANT_HOME_BASE}/${tenantId}/nanoclaw/compose/docker-compose.yml`;
      const composeExists = await sh("test", ["-f", composePath]);
      if (composeExists.ok) {
        await sh("docker", ["compose", "-f", composePath, "down", "--remove-orphans", "-v", "--rmi", "all"], { timeout: 30000 });
      }
    } catch (e) {
      console.error(`Compose cleanup for ${tenantId}: ${e.message}`);
    }
    // Remove any standalone containers created by the tenant's agent
    try {
      const ps = await sh("docker", ["ps", "-a", "--filter", `name=nanoclaw-${tenantId}-`, "--format", "{{.ID}}"]);
      if (ps.ok && ps.stdout.trim()) {
        const ids = ps.stdout.trim().split("\n");
        await sh("docker", ["rm", "-f", ...ids], { timeout: 30000 });
      }
    } catch (e) {
      console.error(`Container cleanup for ${tenantId}: ${e.message}`);
    }
    // Remove NanoClaw-owned compose override files for this tenant.
    try {
      await rm(path.join(NANOCLAW_COMPOSE_OVERRIDE_DIR, tenantId), {
        recursive: true,
        force: true,
      });
    } catch (e) {
      console.error(`Compose override cleanup for ${tenantId}: ${e.message}`);
    }
    // Remove the tenant-private Docker network after containers are gone.
    try {
      await sh("docker", ["network", "rm", getTenantDockerNetworkName(tenantId)], {
        timeout: 30000,
      });
    } catch (e) {
      console.error(`Tenant network cleanup for ${tenantId}: ${e.message}`);
    }
    await deleteTenantCaddyfile(tenantId);

    // 5. Restart nanoclaw
    await sh("systemctl", ["restart", `${NANOCLAW_SERVICE}.service`]);
    return { data: { message: `Tenant '${tenantId}' fully deprovisioned` } };
  },

  // --- Group registration routes (tenant-scoped) ---

  "GET /api/tenants/:tenantId/subdomains": async (body, params) => {
    const rows = await dbQuery(
      "SELECT subdomain FROM tenant_services WHERE tenant_id = ? ORDER BY subdomain",
      [params.tenantId]
    );
    return { data: rows.map(r => r.subdomain) };
  },

  "GET /api/tenants/:tenantId/groups": async (body, params) => {
    const groups = await dbQuery(
      "SELECT jid, name, folder, requires_trigger, is_main, hooks_token, hooks_enabled FROM registered_groups WHERE tenant_id = ?",
      [params.tenantId]
    );
    return { data: groups };
  },

  "POST /api/tenants/:tenantId/groups": async (body, params) => {
    const { jid, name, folder, requiresTrigger = true, isMain = false } = body;
    if (!jid) return { error: "jid is required (e.g. dc:1234567890)", status: 400 };
    if (!folder) return { error: "folder name is required", status: 400 };
    if (!/^[a-zA-Z0-9_-]+$/.test(folder)) return { error: "folder must be alphanumeric with dashes/underscores", status: 400 };

    const tenantId = params.tenantId;
    const compositeFolder = `${tenantId}/${folder}`;

    await createTenantWorkspace(tenantId, folder);

    const result = await dbRun(
      `INSERT OR REPLACE INTO registered_groups (jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger, is_main, tenant_id, hooks_token, hooks_enabled)
       VALUES (?, ?, ?, ?, ?, '{}', ?, ?, ?, ?, 1)`,
      [jid, name || folder, compositeFolder, "@trigger", new Date().toISOString(), requiresTrigger ? 1 : 0, isMain ? 1 : 0, tenantId, randomBytes(32).toString("hex")]
    );

    if (!result.ok) return { error: result.error, status: 500 };

    await sh("systemctl", ["kill", "-s", "HUP", `${NANOCLAW_SERVICE}.service`]);
    return { data: { message: `Group ${jid} registered for tenant '${tenantId}'` } };
  },

  // Add a Discord channel to existing tenant's category
  "POST /api/tenants/:tenantId/channels": async (body, params) => {
    const { name: channelName, requiresTrigger = true } = body;
    const tenantId = params.tenantId;
    if (!channelName) return { error: "name is required", status: 400 };
    if (!/^[a-z0-9-]+$/.test(channelName)) return { error: "channel name must be lowercase with dashes", status: 400 };

    const tenants = await dbQuery("SELECT discord_category_id FROM tenants WHERE id = ?", [tenantId]);
    const tenant = tenants[0];
    if (!tenant?.discord_category_id) {
      return { error: "Tenant has no Discord category", status: 400 };
    }

    const channel = await createTextChannel(DEFAULT_GUILD_ID, channelName, tenant.discord_category_id);
    const jid = `dc:${channel.id}`;
    const compositeFolder = `${tenantId}/${channelName}`;

    await createTenantWorkspace(tenantId, channelName);

    const result = await dbRun(
      `INSERT OR REPLACE INTO registered_groups (jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger, is_main, tenant_id, hooks_token, hooks_enabled)
       VALUES (?, ?, ?, ?, ?, '{}', ?, 0, ?, ?, 1)`,
      [jid, channelName, compositeFolder, "@trigger", new Date().toISOString(), requiresTrigger ? 1 : 0, tenantId, randomBytes(32).toString("hex")]
    );
    if (!result.ok) return { error: result.error, status: 500 };

    await sh("systemctl", ["kill", "-s", "HUP", `${NANOCLAW_SERVICE}.service`]);
    return { data: {
      message: `Channel #${channelName} created for tenant '${tenantId}'`,
      channelId: channel.id,
      jid,
    }};
  },

  "PATCH /api/tenants/:tenantId/groups/:jid": async (body, params) => {
    const jid = decodeURIComponent(params.jid);
    const updates = [];
    const values = [];
    if (body.requires_trigger !== undefined) {
      updates.push("requires_trigger = ?");
      values.push(body.requires_trigger ? 1 : 0);
    }
    if (body.hooks_enabled !== undefined) {
      updates.push("hooks_enabled = ?");
      values.push(body.hooks_enabled ? 1 : 0);
      if (body.hooks_enabled) {
        const [row] = await dbQuery("SELECT hooks_token FROM registered_groups WHERE jid = ?", [jid]);
        if (!row?.hooks_token) {
          updates.push("hooks_token = ?");
          values.push(randomBytes(32).toString("hex"));
        }
      }
    }
    if (body.hooks_rotate) {
      updates.push("hooks_token = ?");
      values.push(randomBytes(32).toString("hex"));
    }
    if (updates.length === 0) return { error: "No fields to update", status: 400 };
    values.push(jid, params.tenantId);
    const result = await dbRun(
      `UPDATE registered_groups SET ${updates.join(", ")} WHERE jid = ? AND tenant_id = ?`,
      values
    );
    if (!result.ok) return { error: result.error, status: 500 };
    await sh("systemctl", ["kill", "-s", "HUP", `${NANOCLAW_SERVICE}.service`]);
    const [updated] = await dbQuery("SELECT jid, name, folder, requires_trigger, is_main, hooks_token, hooks_enabled FROM registered_groups WHERE jid = ?", [jid]);
    return { data: { message: `Group ${jid} updated`, group: updated } };
  },

  "DELETE /api/tenants/:tenantId/groups/:jid": async (body, params) => {
    const jid = decodeURIComponent(params.jid);
    const result = await dbRun(
      "DELETE FROM registered_groups WHERE jid = ? AND tenant_id = ?",
      [jid, params.tenantId]
    );
    if (!result.ok) return { error: result.error, status: 500 };

    await sh("systemctl", ["kill", "-s", "HUP", `${NANOCLAW_SERVICE}.service`]);
    return { data: { message: `Group ${jid} unregistered` } };
  },

  // --- WhatsApp management ---

  "GET /api/whatsapp/status": async () => {
    const authDir = `${NANOCLAW_WORKDIR}/store/auth`;
    const credsPath = `${authDir}/creds.json`;
    try {
      const creds = JSON.parse(await readFile(credsPath, "utf8"));
      const phone = creds.me?.id?.split(":")[0]?.split("@")[0] || null;
      return { data: { connected: !!creds.registered, phone, registered: !!creds.registered } };
    } catch {
      return { data: { connected: false, phone: null, registered: false } };
    }
  },

  "POST /api/whatsapp/auth": async (body) => {
    const { phone } = body;
    if (!phone || !/^\d{8,15}$/.test(phone)) return { error: "Valid phone number required (digits only, with country code, e.g. 351936365670)", status: 400 };

    const authDir = `${NANOCLAW_WORKDIR}/store/auth`;
    const statusFile = `${NANOCLAW_WORKDIR}/store/auth-status.txt`;

    // Clean previous state
    await sh("rm", ["-rf", authDir]).catch(() => {});
    await sh("rm", ["-f", statusFile, `${NANOCLAW_WORKDIR}/store/qr-data.txt`]).catch(() => {});
    await sh("mkdir", ["-p", authDir]);
    await sh("chown", ["nanoclaw:nanoclaw", authDir]);

    // Start auth process in background
    const authScript = `${NANOCLAW_BASE}/src/whatsapp-auth.ts`;
    sh("sudo", ["-u", "nanoclaw", "bash", "-c",
      `cd ${NANOCLAW_WORKDIR} && npx tsx ${authScript} --pairing-code --phone ${phone}`
    ], { timeout: 180000 }).catch(() => {});

    // Poll for pairing code (up to 20s)
    for (let i = 0; i < 20; i++) {
      await new Promise(r => setTimeout(r, 1000));
      try {
        const status = await readFile(statusFile, "utf8");
        if (status.startsWith("pairing_code:")) {
          const code = status.replace("pairing_code:", "").trim();
          return { data: { code, message: "Enter this code on your phone: WhatsApp → Settings → Linked Devices → Link a Device → Link with phone number instead" } };
        }
        if (status === "already_authenticated") {
          return { data: { code: null, message: "Already authenticated" } };
        }
        if (status.startsWith("failed:")) {
          return { error: `Authentication failed: ${status.replace("failed:", "")}`, status: 500 };
        }
      } catch {}
    }
    return { error: "Timed out waiting for pairing code. Try again.", status: 504 };
  },

  "GET /api/whatsapp/auth/status": async () => {
    const statusFile = `${NANOCLAW_WORKDIR}/store/auth-status.txt`;
    const credsPath = `${NANOCLAW_WORKDIR}/store/auth/creds.json`;
    try {
      const status = await readFile(statusFile, "utf8");
      if (status === "authenticated" || status === "already_authenticated") {
        let phone = null;
        try {
          const creds = JSON.parse(await readFile(credsPath, "utf8"));
          phone = creds.me?.id?.split(":")[0]?.split("@")[0] || null;
        } catch {}
        return { data: { status: "authenticated", phone } };
      }
      if (status.startsWith("pairing_code:")) {
        return { data: { status: "waiting", code: status.replace("pairing_code:", "").trim() } };
      }
      if (status.startsWith("failed:")) {
        return { data: { status: "failed", error: status.replace("failed:", "") } };
      }
      return { data: { status: "pending" } };
    } catch {
      // No status file — check if creds exist
      try {
        const creds = JSON.parse(await readFile(credsPath, "utf8"));
        if (creds.registered) {
          const phone = creds.me?.id?.split(":")[0]?.split("@")[0] || null;
          return { data: { status: "authenticated", phone } };
        }
      } catch {}
      return { data: { status: "none" } };
    }
  },

  "POST /api/whatsapp/logout": async () => {
    const authDir = `${NANOCLAW_WORKDIR}/store/auth`;
    const statusFile = `${NANOCLAW_WORKDIR}/store/auth-status.txt`;
    await sh("rm", ["-rf", authDir]).catch(() => {});
    await sh("rm", ["-f", statusFile]).catch(() => {});
    // Remove all WhatsApp registered groups
    await dbRun("DELETE FROM registered_groups WHERE jid LIKE '%@s.whatsapp.net' OR jid LIKE '%@g.us'");
    return { data: { message: "WhatsApp logged out. Restart nanoclaw to disconnect." } };
  },

  "POST /api/tenants/:tenantId/whatsapp-chat": async (body, params) => {
    const { type = "self", jid: customJid, name, requiresTrigger = false } = body;
    const tenantId = params.tenantId;

    // Validate tenant exists
    const tenants = await dbQuery("SELECT id FROM tenants WHERE id = ?", [tenantId]);
    if (tenants.length === 0) return { error: "Tenant not found", status: 404 };

    // Get phone from WhatsApp creds
    let phone;
    try {
      const creds = JSON.parse(await readFile(`${NANOCLAW_WORKDIR}/store/auth/creds.json`, "utf8"));
      phone = creds.me?.id?.split(":")[0]?.split("@")[0];
    } catch {
      return { error: "WhatsApp not authenticated. Set up WhatsApp first.", status: 400 };
    }

    let jid, chatName, folder;
    if (type === "self") {
      jid = `${phone}@s.whatsapp.net`;
      chatName = name || "WhatsApp Self";
      folder = `${tenantId}/whatsapp_self`;
    } else if (type === "custom" && customJid) {
      jid = customJid;
      chatName = name || customJid;
      const safeName = (name || "wa_chat").toLowerCase().replace(/[^a-z0-9_-]/g, "_").slice(0, 40);
      folder = `${tenantId}/${safeName}`;
    } else {
      return { error: "Invalid type. Use 'self' or 'custom' with a jid.", status: 400 };
    }

    // Check if already registered
    const existing = await dbQuery("SELECT jid FROM registered_groups WHERE jid = ?", [jid]);
    if (existing.length > 0) return { error: `Chat ${jid} is already registered`, status: 409 };

    // Create workspace
    const folderName = folder.split("/").pop();
    await createTenantWorkspace(tenantId, folderName);

    // Register in DB
    const result = await dbRun(
      `INSERT INTO registered_groups (jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger, is_main, tenant_id)
       VALUES (?, ?, ?, ?, ?, '{}', ?, 0, ?)`,
      [jid, chatName, folder, "@trigger", new Date().toISOString(), requiresTrigger ? 1 : 0, tenantId]
    );
    if (!result.ok) return { error: result.error, status: 500 };

    await sh("systemctl", ["kill", "-s", "HUP", `${NANOCLAW_SERVICE}.service`]);
    return { data: { message: `WhatsApp chat registered for ${tenantId}`, jid, folder } };
  },

  // --- Tenant services & Caddy management ---

  "GET /api/tenants/:tenantId/services": async (body, params) => {
    const tenantId = params.tenantId;
    const tenant = (await dbQuery("SELECT port_range_start, port_range_size FROM tenants WHERE id = ?", [tenantId]))[0];
    if (!tenant) return { error: "Tenant not found", status: 404 };

    const services = await getTenantServices(tenantId);
    const caddyfile = await readTenantCaddyfile(tenantId);

    return { data: {
      port_range_start: tenant.port_range_start,
      port_range_size: tenant.port_range_size || PORT_RANGE_SIZE,
      ...services,
      caddyfile,
    }};
  },


  "GET /api/tenants/:tenantId/disk-usage": async (body, params) => {
    const tenantId = params.tenantId;
    const tenant = (await dbQuery("SELECT id FROM tenants WHERE id = ?", [tenantId]))[0];
    if (!tenant) return { error: "Tenant not found", status: 404 };
    try {
      const result = await sh("sudo", [TENANT_RUNTIME_HELPER, tenantId, "runtime-disk-usage"], { timeout: 30000 });
      if (!result.ok) return { error: result.stderr || "runtime-disk-usage failed", status: 500 };
      const data = JSON.parse(result.stdout);
      return { data };
    } catch (e) {
      return { error: e.message || "disk usage failed", status: 500 };
    }
  },

  "GET /api/tenants/:tenantId/caddyfile": async (body, params) => {
    const content = await readTenantCaddyfile(params.tenantId);
    if (content === null) return { error: "No Caddyfile found", status: 404 };
    return { data: { content } };
  },

  "POST /api/tenants/:tenantId/tailscale-proxy": async (body, params) => {
    const { enable } = body;
    if (typeof enable !== "boolean") return { error: "enable must be a boolean", status: 400 };
    const rows = await dbQuery("SELECT id FROM tenants WHERE id = ?", [params.tenantId]);
    if (!rows[0]) return { error: "Tenant not found", status: 404 };
    try {
      await updateTenantNixConfig(params.tenantId, { tailscaleProxy: enable });
    } catch (e) {
      return { error: e.message, status: 500 };
    }
    const rebuild = await nixosRebuildSwitch();
    if (!rebuild.ok) return { error: `nixos-rebuild failed: ${rebuild.stderr}`, status: 500 };
    // Write/remove proxy.json in each registered group's IPC dir so tc proxy works in containers
    // Use the tenant's Docker bridge gateway (reachable from inside containers) rather than the
    // Tailscale IP (which is only reachable from the host).
    const SOCKS5_PORT = 1055;
    const networkName = `nanoclaw-tenant-${params.tenantId}`;
    let proxyHost = "100.67.100.124"; // fallback to Tailscale IP
    try {
      const { execFileSync: execF } = await import("child_process");
      const inspectOut = execF("docker", ["network", "inspect", networkName, "--format", "{{range .IPAM.Config}}{{.Gateway}}{{end}}"], { encoding: "utf8", timeout: 5000 });
      const gw = inspectOut.trim();
      if (gw) proxyHost = gw;
    } catch { /* network may not exist yet; fall back */ }
    const SOCKS5_URL = `socks5://${proxyHost}:${SOCKS5_PORT}`;
    const tenantIpcBase = path.join(TENANT_HOME_BASE, params.tenantId, "nanoclaw", "data", "ipc");
    const ipcEntries = await readdir(tenantIpcBase, { withFileTypes: true }).catch(() => []);
    const ipcFolders = ipcEntries.filter(e => e.isDirectory()).map(e => e.name);
    for (const folderName of ipcFolders) {
      const ipcDir = path.join(tenantIpcBase, folderName);
      const proxyFile = path.join(ipcDir, "proxy.json");
      if (enable) {
        await mkdir(ipcDir, { recursive: true });
        await writeFileFs(proxyFile, JSON.stringify({ url: SOCKS5_URL, protocol: "socks5", host: proxyHost, port: SOCKS5_PORT }, null, 2));
      } else {
        await unlinkFs(proxyFile).catch(() => {});
      }
    }
    // nixos-rebuild (called above) flushes PROXY_ALLOW; repopulate all tenant rules,
    // then add/remove the rule for this specific tenant.
    await repopulateProxyIptablesRules();
    const subnet = await getDockerBridgeSubnet(params.tenantId);
    if (subnet) {
      if (enable) addProxyIptablesRule(subnet);
      else removeProxyIptablesRule(subnet);
    }
    return { data: { message: `Tailscale proxy ${enable ? "enabled" : "disabled"} for ${params.tenantId}` } };
  },

  "PUT /api/tenants/:tenantId/caddyfile": async (body, params) => {
    const { content } = body;
    if (!content && content !== "") return { error: "content is required", status: 400 };

    // Validate: tenant must exist
    const tenant = (await dbQuery("SELECT port_range_start, port_range_size FROM tenants WHERE id = ?", [params.tenantId]))[0];
    if (!tenant) return { error: "Tenant not found", status: 404 };

    // Safety: only allow *.tenantId.devitltd.uk domains in the Caddyfile
    // Parse for domain patterns that don't match the tenant's namespace
    const domainPattern = /^[a-zA-Z0-9*.-]+\.devitltd\.uk/gm;
    const domains = content.match(domainPattern) || [];
    for (const domain of domains) {
      if (!domain.endsWith(`.${params.tenantId}.devitltd.uk`)) {
        return { error: `Domain '${domain}' is not in tenant namespace (*.${params.tenantId}.devitltd.uk)`, status: 400 };
      }
    }

    await updateTenantCaddyfile(params.tenantId, content);
    return { data: { message: "Caddyfile updated and Caddy reloaded" } };
  },

  "POST /api/tenants/:tenantId/compose/up": async (body, params) => {
    const composePath = `${TENANT_HOME_BASE}/${params.tenantId}/nanoclaw/compose/docker-compose.yml`;
    const exists = await sh("test", ["-f", composePath]);
    if (!exists.ok) return { error: "No docker-compose.yml found", status: 404 };

    const result = await sh("docker", ["compose", "-f", composePath, "up", "-d"], { timeout: 120000 });
    if (!result.ok) return { error: `compose up failed: ${result.stderr}`, status: 500 };
    return { data: { message: "Services started", output: result.stdout } };
  },

  "POST /api/tenants/:tenantId/compose/down": async (body, params) => {
    const composePath = `${TENANT_HOME_BASE}/${params.tenantId}/nanoclaw/compose/docker-compose.yml`;
    const exists = await sh("test", ["-f", composePath]);
    if (!exists.ok) return { error: "No docker-compose.yml found", status: 404 };

    const result = await sh("docker", ["compose", "-f", composePath, "down"], { timeout: 30000 });
    if (!result.ok) return { error: `compose down failed: ${result.stderr}`, status: 500 };
    return { data: { message: "Services stopped", output: result.stdout } };
  },

  "GET /api/tenants/:tenantId/compose/logs": async (body, params) => {
    const composePath = `${TENANT_HOME_BASE}/${params.tenantId}/nanoclaw/compose/docker-compose.yml`;
    const exists = await sh("test", ["-f", composePath]);
    if (!exists.ok) return { error: "No docker-compose.yml found", status: 404 };

    const result = await sh("docker", ["compose", "-f", composePath, "logs", "--tail", "100"], { timeout: 10000 });
    return { data: { logs: result.ok ? result.stdout : result.stderr } };
  },

  // --- Caddy config viewer ---

  "GET /api/caddy": async () => {
    // Read the main NixOS-generated Caddy config
    let systemConfig = "";
    try {
      systemConfig = await readFile("/etc/caddy/caddy_config", "utf8");
    } catch { systemConfig = "(could not read /etc/caddy/caddy_config)"; }

    // Read all tenant caddyfiles
    const tenantFiles = [];
    try {
      const files = await readdir(CADDY_TENANT_DIR);
      for (const f of files.sort()) {
        if (!f.endsWith(".caddy")) continue;
        const tenantId = f.replace(/\.caddy$/, "");
        try {
          const content = await readFile(`${CADDY_TENANT_DIR}/${f}`, "utf8");
          tenantFiles.push({ tenantId, filename: f, content });
        } catch {}
      }
    } catch {}

    return { data: { systemConfig, tenantFiles } };
  },

  // --- Service management ---

  "POST /api/service/restart": async () => {
    const result = await sh("systemctl", ["restart", `${NANOCLAW_SERVICE}.service`]);
    return result.ok
      ? { data: { message: "NanoClaw restarted" } }
      : { error: result.stderr, status: 500 };
  },

  "POST /api/service/stop": async () => {
    const result = await sh("systemctl", ["stop", `${NANOCLAW_SERVICE}.service`]);
    return result.ok
      ? { data: { message: "NanoClaw stopped" } }
      : { error: result.stderr, status: 500 };
  },

  "POST /api/service/start": async () => {
    const result = await sh("systemctl", ["start", `${NANOCLAW_SERVICE}.service`]);
    return result.ok
      ? { data: { message: "NanoClaw started" } }
      : { error: result.stderr, status: 500 };
  },

  "GET /api/service/logs": async () => {
    const result = await sh("journalctl", [
      "-u", `${NANOCLAW_SERVICE}.service`,
      "--no-pager", "-n", "100", "--output=short-iso",
    ]);
    return { data: { logs: result.ok ? result.stdout : result.stderr } };
  },

  // --- Credential Pool ---

  "GET /api/credentials": async () => {
    const creds = await dbQuery("SELECT id, provider, pi_backend, type, label, status, expires_at, plan_type, rate_limit_tier, last_used_at, total_requests, error_message, created_at, auth_json FROM credentials ORDER BY created_at");
    return { data: creds.map((c) => ({ ...c, account_id: extractAccountId(c.auth_json, c.provider), auth_json: undefined })) };
  },

  "GET /api/credentials/:credId": async (body, params) => {
    const rows = await dbQuery("SELECT * FROM credentials WHERE id = ?", [params.credId]);
    if (!rows.length) return { error: "Credential not found", status: 404 };
    const cred = rows[0];
    // Mask tokens for display
    if (cred.access_token) cred.access_token = cred.access_token.substring(0, 30) + "...";
    if (cred.refresh_token) cred.refresh_token = cred.refresh_token.substring(0, 30) + "...";
    if (cred.api_key) cred.api_key = cred.api_key.substring(0, 20) + "...";
    return { data: cred };
  },

  "POST /api/credentials": async (body) => {
    const { id, provider, type, label, plan_type, pi_backend } = body;
    if (!id || !type) return { error: "id and type required", status: 400 };
    if (provider !== undefined && !["claude", "codex", "pi"].includes(provider)) return { error: "provider must be claude, codex, or pi", status: 400 };
    if (pi_backend !== undefined && provider !== "pi") return { error: "pi_backend can only be set when provider=pi", status: 400 };
    if (!["oauth", "apikey"].includes(type)) return { error: "type must be oauth or apikey", status: 400 };
    if (!/^[a-z0-9][a-z0-9-]*$/.test(id)) return { error: "id must be lowercase alphanumeric with hyphens", status: 400 };

    // Check for existing credential with same id
    const existing = await dbQuery("SELECT id FROM credentials WHERE id = ?", [id]);
    if (existing.length) return { error: `Credential '${id}' already exists`, status: 409 };

    if ((provider || "claude") === "codex" && type === "oauth") {
      try {
        const authJson = loadHostCodexAuth();
        const now = new Date().toISOString();
        await dbRun(
          `INSERT INTO credentials (id, provider, type, label, plan_type, status, auth_json, total_requests, created_at)
           VALUES (?, 'codex', 'oauth', ?, ?, 'active', ?, 0, ?)`,
          [id, label || null, plan_type || null, authJson, now]
        );
        return { data: { id, message: "Imported current host Codex login into credential pool" } };
      } catch (err) {
        return { error: `Failed to import host Codex login: ${err.message}`, status: 500 };
      }
    } else if (provider === "pi" && type === "oauth") {
      // Pi credential — create entry (auth_json imported separately)
      const now = new Date().toISOString();
      await dbRun(
        `INSERT INTO credentials (id, provider, pi_backend, type, label, plan_type, status, auth_json, total_requests, created_at)
         VALUES (?, 'pi', ?, 'oauth', ?, ?, 'paused', NULL, 0, ?)`,
        [id, pi_backend || null, label || null, plan_type || null, now]
      );
      return { data: { id, message: "Pi credential created. Use re-auth to import the pi auth.json from the host." } };
    } else if (type === "oauth") {
      // Start OAuth PKCE flow — return authorize URL for user to visit
      const codeVerifier = generateCodeVerifier();
      const codeChallenge = await generateCodeChallenge(codeVerifier);
      const state = randomBytes(16).toString("hex");

      pendingOAuthFlows.set(id, { codeVerifier, state, label, plan_type, createdAt: Date.now() });
      // Clean up stale flows older than 10 minutes
      for (const [k, v] of pendingOAuthFlows) {
        if (Date.now() - v.createdAt > 600000) pendingOAuthFlows.delete(k);
      }

      const params = new URLSearchParams({
        code: "true",
        client_id: OAUTH_CLIENT_ID,
        response_type: "code",
        redirect_uri: OAUTH_REDIRECT_URL,
        scope: OAUTH_SCOPES,
        state,
        code_challenge: codeChallenge,
        code_challenge_method: "S256",
      });

      const authorizeUrl = `${OAUTH_AUTHORIZE_URL}?${params}`;
      return {
        data: {
          id,
          message: "Open the authorize URL in your browser, authenticate, then copy the code from the callback page and POST it to /api/credentials/:id/callback",
          authorize_url: authorizeUrl,
          state,
        }
      };
    } else {
      const { api_key } = body;
      if (!api_key) return { error: "api_key required for API key credentials", status: 400 };
      const now = new Date().toISOString();
      await dbRun(
        `INSERT INTO credentials (id, provider, type, label, plan_type, status, api_key, total_requests, created_at)
         VALUES (?, ?, 'apikey', ?, ?, 'active', ?, 0, ?)`,
        [id, provider || "claude", label || null, plan_type || null, api_key, now]
      );
      return { data: { id, message: "API key credential added" } };
    }
  },

  "POST /api/credentials/:credId/callback": async (body, params) => {
    let { code } = body;
    if (!code) return { error: "code is required", status: 400 };

    // Strip #state suffix if present (callback page appends it)
    code = code.split("#")[0];

    const pending = pendingOAuthFlows.get(params.credId);
    if (!pending) return { error: "No pending OAuth flow for this credential ID. Start one with POST /api/credentials first.", status: 404 };

    pendingOAuthFlows.delete(params.credId);

    try {
      const res = await fetch(OAUTH_TOKEN_URL, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          grant_type: "authorization_code",
          code,
          redirect_uri: OAUTH_REDIRECT_URL,
          client_id: OAUTH_CLIENT_ID,
          code_verifier: pending.codeVerifier,
          state: pending.state,
        }),
        signal: AbortSignal.timeout(15000),
      });
      if (!res.ok) {
        const err = await res.text();
        return { error: `Token exchange failed (${res.status}): ${err}`, status: 502 };
      }
      const data = await res.json();
      const expiresAt = Date.now() + data.expires_in * 1000;
      const now = new Date().toISOString();

      // Build claudeAiOauth auth_json — merge with existing if present
      const existingRows = await dbQuery("SELECT auth_json, plan_type FROM credentials WHERE id = ?", [params.credId]);
      const existingAuthJson = existingRows[0]?.auth_json;
      let authJsonObj = existingAuthJson ? JSON.parse(existingAuthJson) : {};
      if (!authJsonObj.claudeAiOauth) authJsonObj.claudeAiOauth = {};
      authJsonObj.claudeAiOauth.accessToken = data.access_token;
      authJsonObj.claudeAiOauth.refreshToken = data.refresh_token;
      authJsonObj.claudeAiOauth.expiresAt = expiresAt;
      if (data.scope) authJsonObj.claudeAiOauth.scopes = data.scope.split(" ");
      if (data.rate_limit_tier) authJsonObj.claudeAiOauth.rateLimitTier = data.rate_limit_tier;
      const authJson = JSON.stringify(authJsonObj);

      await dbRun(
        `INSERT INTO credentials (id, provider, type, label, plan_type, status, access_token, refresh_token, expires_at, rate_limit_tier, auth_json, total_requests, error_message, created_at)
         VALUES (?, 'claude', 'oauth', ?, ?, 'active', ?, ?, ?, ?, ?, 0, NULL, ?)
         ON CONFLICT(id) DO UPDATE SET
           provider = excluded.provider,
           type = excluded.type,
           label = COALESCE(excluded.label, credentials.label),
           plan_type = COALESCE(excluded.plan_type, credentials.plan_type),
           status = excluded.status,
           access_token = excluded.access_token,
           refresh_token = excluded.refresh_token,
           expires_at = excluded.expires_at,
           rate_limit_tier = excluded.rate_limit_tier,
           auth_json = excluded.auth_json,
           error_message = NULL`,
        [params.credId, pending.label || null, pending.plan_type || null, data.access_token, data.refresh_token, expiresAt, data.rate_limit_tier || null, authJson, now]
      );

      // Push fresh token to all active container token files immediately
      pushTokenToContainerFiles(params.credId, data.access_token);

      return {
        data: {
          id: params.credId,
          message: "OAuth credential created successfully",
          expires_in: data.expires_in,
        }
      };
    } catch (err) {
      return { error: `Token exchange error: ${err.message}`, status: 500 };
    }
  },

  "POST /api/credentials/:credId/relogin": async (body, params) => {
    const { credId } = params;
    const rows = await dbQuery("SELECT * FROM credentials WHERE id = ?", [credId]);
    if (!rows.length) return { error: "Credential not found", status: 404 };
    const cred = rows[0];
    if ((cred.provider || "claude") === "codex") {
      return { error: "Codex re-auth uses auth_json import. Use POST /api/credentials/:credId/import-auth", status: 400 };
    }
    if ((cred.provider || "claude") !== "claude") return { error: "Only Claude OAuth credentials can be re-logged-in", status: 400 };
    if (cred.type !== "oauth") return { error: "Only OAuth credentials can be re-logged-in", status: 400 };

    const codeVerifier = generateCodeVerifier();
    const codeChallenge = await generateCodeChallenge(codeVerifier);
    const state = randomBytes(16).toString("hex");

    pendingOAuthFlows.set(credId, {
      codeVerifier,
      state,
      label: cred.label || null,
      plan_type: cred.plan_type || null,
      createdAt: Date.now(),
    });
    for (const [k, v] of pendingOAuthFlows) {
      if (Date.now() - v.createdAt > 600000) pendingOAuthFlows.delete(k);
    }

    const isMaxPlan = cred.plan_type === "max";
    const authorizeBase = isMaxPlan ? CLAUDE_AI_AUTHORIZE_URL : OAUTH_AUTHORIZE_URL;
    const scopes = isMaxPlan ? CLAUDE_AI_SCOPES : OAUTH_SCOPES;
    const paramsQ = new URLSearchParams({
      code: "true",
      client_id: OAUTH_CLIENT_ID,
      response_type: "code",
      redirect_uri: OAUTH_REDIRECT_URL,
      scope: scopes,
      state,
      code_challenge: codeChallenge,
      code_challenge_method: "S256",
    });
    const authorizeUrl = `${authorizeBase}?${paramsQ}`;
    return {
      data: {
        id: credId,
        message: "Open the authorize URL in your browser, authenticate, then POST the code to /api/credentials/:id/callback to update tokens",
        authorize_url: authorizeUrl,
        state,
      },
    };
  },

  "PATCH /api/credentials/:credId": async (body, params) => {
    const { status, label, plan_type } = body;
    const updates = [];
    const values = [];
    if (status) {
      if (!["active", "paused"].includes(status)) return { error: "status must be active or paused", status: 400 };
      updates.push("status = ?");
      values.push(status);
    }
    if (label !== undefined) {
      updates.push("label = ?");
      values.push(label);
    }
    if (plan_type !== undefined) {
      updates.push("plan_type = ?");
      values.push(plan_type);
    }
    if (!updates.length) return { error: "Nothing to update", status: 400 };
    values.push(params.credId);
    const result = await dbRun(`UPDATE credentials SET ${updates.join(", ")} WHERE id = ?`, values);
    return result.ok ? { data: { message: "Credential updated" } } : { error: result.error, status: 500 };
  },

  "POST /api/credentials/:credId/import-auth": async (body, params) => {
    const authJson = body?.auth_json;
    if (!authJson || typeof authJson !== "string") return { error: "auth_json (string) is required", status: 400 };

    const rows = await dbQuery("SELECT * FROM credentials WHERE id = ?", [params.credId]);
    if (!rows.length) return { error: "Credential not found", status: 404 };
    const cred = rows[0];
    const provider = cred.provider || "claude";
    if (cred.type !== "oauth") return { error: "Only OAuth credentials support auth_json import", status: 400 };

    if (provider === "codex") {
      const expiresAt = codexAuthExpiresAt(authJson);
      const result = await dbRun(
        "UPDATE credentials SET auth_json = ?, status = 'active', error_message = NULL, expires_at = ? WHERE id = ?",
        [authJson, expiresAt, params.credId]
      );
      return result.ok ? { data: { message: "Auth JSON imported" } } : { error: result.error, status: 500 };
    }

    if (provider === "claude") {
      // Import Claude Code ~/.claude/.credentials.json format. We store it in auth_json for DRY usage
      // across CLI (interactive /usage) and platform model calls.
      let parsed = null;
      try {
        parsed = JSON.parse(authJson);
      } catch {
        return { error: "Invalid JSON", status: 400 };
      }
      const oauth = parsed?.claudeAiOauth;
      if (!oauth || typeof oauth !== "object") return { error: "Missing claudeAiOauth object", status: 400 };
      const accessToken = oauth.accessToken;
      const refreshToken = oauth.refreshToken;
      const expiresAt = oauth.expiresAt;
      const subscriptionType = oauth.subscriptionType;
      const rateLimitTier = oauth.rateLimitTier;
      if (typeof accessToken !== "string" || !accessToken.trim()) return { error: "Missing claudeAiOauth.accessToken", status: 400 };
      if (typeof refreshToken !== "string" || !refreshToken.trim()) return { error: "Missing claudeAiOauth.refreshToken", status: 400 };
      if (typeof expiresAt !== "number" || !Number.isFinite(expiresAt)) return { error: "Missing claudeAiOauth.expiresAt", status: 400 };

      const result = await dbRun(
        `UPDATE credentials
         SET auth_json = ?,
             access_token = ?,
             refresh_token = ?,
             expires_at = ?,
             plan_type = COALESCE(?, plan_type),
             rate_limit_tier = COALESCE(?, rate_limit_tier),
             status = 'active',
             error_message = NULL
         WHERE id = ?`,
        [
          authJson,
          accessToken,
          refreshToken,
          expiresAt,
          typeof subscriptionType === "string" ? subscriptionType : null,
          typeof rateLimitTier === "string" ? rateLimitTier : null,
          params.credId,
        ]
      );
      return result.ok ? { data: { message: "Claude credentials imported" } } : { error: result.error, status: 500 };
    }

    if (provider === "pi") {
      // Store raw pi auth.json blob
      try { JSON.parse(authJson); } catch { return { error: "Invalid JSON", status: 400 }; }
      const result = await dbRun(
        "UPDATE credentials SET auth_json = ?, status = 'active', error_message = NULL WHERE id = ?",
        [authJson, params.credId]
      );
      return result.ok ? { data: { message: "Pi auth.json imported" } } : { error: result.error, status: 500 };
    }

    return { error: "Unsupported provider for auth_json import", status: 400 };
  },

  "POST /api/credentials/:credId/import-pi-host-auth": async (body, params) => {
    const rows = await dbQuery("SELECT * FROM credentials WHERE id = ?", [params.credId]);
    if (!rows.length) return { error: "Credential not found", status: 404 };
    const cred = rows[0];
    if ((cred.provider || "claude") !== "pi") return { error: "Credential must have provider=pi", status: 400 };
    try {
      const raw = loadHostPiAuth();
      let authJsonToStore = raw;
      if (cred.pi_backend) {
        // Extract just this provider's section from the full auth.json
        const parsed = JSON.parse(raw);
        const section = parsed[cred.pi_backend];
        if (!section) return { error: `No auth entry found for provider '${cred.pi_backend}' in ~/.pi/agent/auth.json. Run: pi /login first.`, status: 404 };
        const sectionWithType = section.type ? section : { type: 'oauth', ...section };
        authJsonToStore = JSON.stringify({ [cred.pi_backend]: sectionWithType });
      }
      const result = await dbRun(
        "UPDATE credentials SET auth_json = ?, status = 'active', error_message = NULL WHERE id = ?",
        [authJsonToStore, params.credId]
      );
      const providerLabel = cred.pi_backend || "all providers";
      return result.ok ? { data: { message: `Imported pi auth.json for ${providerLabel}` } } : { error: result.error, status: 500 };
    } catch (err) {
      return { error: err.message, status: 500 };
    }
  },

  "POST /api/credentials/:credId/codex-device/start": async (body, params) => {
    const { credId } = params;
    const rows = await dbQuery("SELECT * FROM credentials WHERE id = ?", [credId]);
    if (!rows.length) return { error: "Credential not found", status: 404 };
    const cred = rows[0];
    const provider = cred.provider || "claude";
    if (provider !== "codex") return { error: "Only Codex credentials support device auth", status: 400 };
    if (cred.type !== "oauth") return { error: "Only OAuth credentials support device auth", status: 400 };

    const existing = pendingCodexDeviceFlows.get(credId);
    if (existing?.child && existing.child.exitCode === null) {
      try { existing.child.kill("SIGTERM"); } catch {}
      pendingCodexDeviceFlows.delete(credId);
    }
    if (existing?.tmpDir) {
      try { (await import("node:fs/promises")).rm(existing.tmpDir, { recursive: true, force: true }); } catch {}
    }

    const { mkdtempSync, mkdirSync } = await import("node:fs");
    const tmpDir = mkdtempSync("/tmp/codex-device-");
    const codexHome = tmpDir + "/.codex";
    mkdirSync(codexHome, { recursive: true });

    const codexPath = [
      "/home/admin/.npm-global/bin",
      "/run/current-system/sw/bin",
      process.env.PATH || "",
    ].filter(Boolean).join(":");
    const child = spawn("codex", ["login", "--device-auth"], {
      env: { ...process.env, PATH: codexPath, HOME: tmpDir },
      stdio: ["ignore", "pipe", "pipe"],
    });

    const state = { child, tmpDir, url: null, code: null, output: "", startedAt: Date.now() };
    pendingCodexDeviceFlows.set(credId, state);

    const onData = (chunk) => {
      const s = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
      state.output += s;
      const parsed = parseCodexDeviceAuthOutput(state.output);
      if (parsed.url) state.url = parsed.url;
      if (parsed.code) state.code = parsed.code;
    };
    child.stdout.on("data", onData);
    child.stderr.on("data", onData);

    // If codex exits early, keep whatever we captured (and allow restart).
    child.once("exit", () => {});

    const started = Date.now();
    while (Date.now() - started < 8000) {
      if (state.url && state.code) break;
      await new Promise((r) => setTimeout(r, 100));
    }

    if (!state.url || !state.code) {
      const debugOutput = stripAnsi(state.output).trim().slice(0, 400);
      if (debugOutput.includes("429") || debugOutput.toLowerCase().includes("too many requests")) {
        return { error: "Codex rate limit hit — wait a minute then try again.", status: 429 };
      }
      if (debugOutput.includes("401") || debugOutput.toLowerCase().includes("unauthorized")) {
        return { error: "Codex auth failed (401). Check that codex is properly installed on the host.", status: 502 };
      }
      return { error: `Codex device auth failed. ${debugOutput || "No output — check that codex is installed on the host."}`, status: 502 };
    }

    return {
      data: {
        id: credId,
        authorize_url: state.url,
        user_code: state.code,
        message: "Open the authorize URL, enter the code, then click Finish to import auth.json into this credential.",
      },
    };
  },

  "POST /api/credentials/:credId/codex-device/finish": async (body, params) => {
    const { credId } = params;
    const state = pendingCodexDeviceFlows.get(credId);
    if (!state) return { error: "No pending Codex device auth for this credential. Start one first.", status: 404 };

    const exited = await new Promise((resolve) => {
      if (state.child.exitCode !== null) return resolve(true);
      const timer = setTimeout(() => resolve(false), 30000);
      state.child.once("exit", () => {
        clearTimeout(timer);
        resolve(true);
      });
    });

    if (!exited) {
      return {
        data: {
          id: credId,
          status: "pending",
          message: "Still waiting for Codex device authentication to complete.",
          authorize_url: state.url,
          user_code: state.code,
        },
      };
    }

    const { tmpDir } = state;
    pendingCodexDeviceFlows.delete(credId);

    try {
      const authJsonPath = tmpDir + "/.codex/auth.json";
      const authJson = readFileSync(authJsonPath, "utf8");
      const parsed = JSON.parse(authJson);
      if (!parsed?.tokens?.access_token || !parsed?.tokens?.refresh_token) {
        throw new Error("auth.json missing tokens after device auth");
      }
      const expiresAt = codexAuthExpiresAt(authJson);
      const result = await dbRun(
        "UPDATE credentials SET auth_json = ?, status = 'active', error_message = NULL, expires_at = ? WHERE id = ?",
        [authJson, expiresAt, credId]
      );
      if (!result.ok) return { error: result.error, status: 500 };
      return { data: { id: credId, status: "ok", message: "Codex credential re-authenticated" } };
    } catch (err) {
      return { error: `Failed to import Codex auth: ${err.message}`, status: 500 };
    } finally {
      try { (await import("node:fs/promises")).rm(tmpDir, { recursive: true, force: true }); } catch {}
    }
  },

  "GET /api/credentials/:credId/codex-device/status": async (body, params) => {
    const { credId } = params;
    const state = pendingCodexDeviceFlows.get(credId);
    if (!state) return { data: { status: "none" } };
    if (state.child.exitCode === null) return { data: { status: "pending", authorize_url: state.url, user_code: state.code } };
    // Process exited — auto-import
    const { tmpDir } = state;
    pendingCodexDeviceFlows.delete(credId);
    try {
      const authJsonPath = tmpDir + "/.codex/auth.json";
      const authJson = readFileSync(authJsonPath, "utf8");
      const parsed = JSON.parse(authJson);
      if (!parsed?.tokens?.access_token || !parsed?.tokens?.refresh_token) {
        throw new Error("auth.json missing tokens after device auth");
      }
      const expiresAt = codexAuthExpiresAt(authJson);
      const result = await dbRun(
        "UPDATE credentials SET auth_json = ?, status = 'active', error_message = NULL, expires_at = ? WHERE id = ?",
        [authJson, expiresAt, credId]
      );
      if (!result.ok) return { error: result.error, status: 500 };
      return { data: { status: "ok", id: credId, message: "Codex credential re-authenticated" } };
    } catch (err) {
      return { error: `Failed to import Codex auth: ${err.message}`, status: 500 };
    } finally {
      try { (await import("node:fs/promises")).rm(tmpDir, { recursive: true, force: true }); } catch {}
    }
  },


  "POST /api/credentials/:credId/pi-login/start": async (body, params) => {
    const { credId } = params;
    const rows = await dbQuery("SELECT * FROM credentials WHERE id = ?", [credId]);
    if (!rows.length) return { error: "Credential not found", status: 404 };
    const cred = rows[0];
    if ((cred.provider || "claude") !== "pi") return { error: "Only pi credentials support pi-login", status: 400 };
    if (!cred.pi_backend) return { error: "Credential has no pi_backend set", status: 400 };

    // Cancel any existing flow for this credential
    const existing = pendingPiLoginFlows.get(credId);
    if (existing && !existing.done) {
      existing.cancelled = true;
      if (existing.rejectManualInput) {
        existing.rejectManualInput(new Error('New login flow started'));
        existing.resolveManualInput = null;
        existing.rejectManualInput = null;
      }
    }

    // --- Anthropic: use our own PKCE flow with platform.claude.com/oauth/code/callback ---
    // Same client_id as pi. No localhost server needed — user gets the nice "copy this code" page.
    if (cred.pi_backend === "anthropic") {
      const codeVerifier = generateCodeVerifier();
      const codeChallenge = await generateCodeChallenge(codeVerifier);
      const oauthState = randomBytes(16).toString("hex");
      const authParams = new URLSearchParams({
        code: "true",
        client_id: OAUTH_CLIENT_ID,
        response_type: "code",
        redirect_uri: OAUTH_REDIRECT_URL,
        scope: "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload",
        state: oauthState,
        code_challenge: codeChallenge,
        code_challenge_method: "S256",
      });
      const authorizeUrl = `https://claude.ai/oauth/authorize?${authParams}`;
      const state = {
        url: authorizeUrl,
        instructions: "After authenticating, claude.ai will show you an authentication code. Copy it and paste it below.",
        progress: [],
        done: false,
        error: null,
        cancelled: false,
        resolveManualInput: null,
        rejectManualInput: null,
        needsManualCode: true,
        anthropicPkce: { codeVerifier, oauthState },
      };
      pendingPiLoginFlows.set(credId, state);
      return { data: { authorize_url: authorizeUrl, status: "awaiting_user", needsManualCode: true, instructions: state.instructions } };
    }

    // --- Google Gemini CLI: own PKCE flow (no localhost callback needed) ---
    if (cred.pi_backend === "google-gemini-cli") {
      const codeVerifier = generateCodeVerifier();
      const codeChallenge = await generateCodeChallenge(codeVerifier);
      const GEMINI_CLIENT_ID = atob("NjgxMjU1ODA5Mzk1LW9vOGZ0Mm9wcmRybnA5ZTNhcWY2YXYzaG1kaWIxMzVqLmFwcHMuZ29vZ2xldXNlcmNvbnRlbnQuY29t");
      const GEMINI_SCOPES = "https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile";
      const GEMINI_REDIRECT_URI = "http://localhost:8085/oauth2callback";
      const authParams = new URLSearchParams({
        client_id: GEMINI_CLIENT_ID,
        response_type: "code",
        redirect_uri: GEMINI_REDIRECT_URI,
        scope: GEMINI_SCOPES,
        code_challenge: codeChallenge,
        code_challenge_method: "S256",
        state: codeVerifier,
        access_type: "offline",
        prompt: "consent",
      });
      const authorizeUrl = `https://accounts.google.com/o/oauth2/v2/auth?${authParams}`;
      const state = {
        url: authorizeUrl,
        instructions: "After authenticating, your browser will redirect to localhost:8085 (which will fail). Copy the full URL from your browser's address bar and paste it below.",
        progress: [],
        done: false,
        error: null,
        cancelled: false,
        resolveManualInput: null,
        rejectManualInput: null,
        needsManualCode: true,
        geminiPkce: { codeVerifier, clientId: GEMINI_CLIENT_ID, clientSecret: atob("R09DU1BYLTR1SGdNUG0tMW83U2stZ2VWNkN1NWNsWEZzeGw="), redirectUri: GEMINI_REDIRECT_URI },
      };
      pendingPiLoginFlows.set(credId, state);
      return { data: { authorize_url: authorizeUrl, status: "awaiting_user", needsManualCode: true, instructions: state.instructions } };
    }

    // --- Other backends: use pi-ai OAuth (localhost callback server) ---
    const piAiPath = "/home/admin/treehouse/nanoclaw/container/agent-runner/node_modules/@mariozechner/pi-ai/dist/utils/oauth/index.js";
    let getOAuthProvider;
    try {
      const mod = await import(piAiPath);
      getOAuthProvider = mod.getOAuthProvider;
    } catch (e) {
      return { error: `Failed to load pi-ai oauth module: ${e.message}`, status: 500 };
    }

    const oauthProvider = getOAuthProvider(cred.pi_backend);
    if (!oauthProvider) return { error: `Backend '${cred.pi_backend}' does not support OAuth login.`, status: 400 };

    const state = { url: null, progress: [], done: false, error: null, cancelled: false, resolveManualInput: null, rejectManualInput: null, needsManualCode: false };
    pendingPiLoginFlows.set(credId, state);

    let resolveUrl, rejectUrl;
    const urlPromise = new Promise((res, rej) => { resolveUrl = res; rejectUrl = rej; });

    const onManualCodeInput = () => new Promise((res, rej) => {
      state.resolveManualInput = res;
      state.rejectManualInput = rej;
    });

    oauthProvider.login({
      onAuth: (info) => {
        state.url = info.url;
        state.instructions = info.instructions || null;
        state.progress.push(info.url);
        if (info.instructions) state.progress.push(info.instructions);
        resolveUrl(info.url);
      },
      onProgress: (msg) => { state.progress.push(msg); },
      onManualCodeInput,
    }).then(async (credentials) => {
      // Always save credentials even if flow was superseded — they are valid tokens
      const { readFileSync, writeFileSync, mkdirSync } = await import("node:fs");
      const authJsonPath = "/home/admin/.pi/agent/auth.json";
      let authFile = {};
      try { authFile = JSON.parse(readFileSync(authJsonPath, "utf8")); } catch {}
      authFile[cred.pi_backend] = { type: "oauth", ...credentials };
      mkdirSync("/home/admin/.pi/agent", { recursive: true });
      writeFileSync(authJsonPath, JSON.stringify(authFile, null, 2), { mode: 0o600 });
      const raw = JSON.stringify({ [cred.pi_backend]: { type: "oauth", ...credentials } });
      await dbRun("UPDATE credentials SET auth_json = ?, status = 'active', error_message = NULL WHERE id = ?", [raw, credId]);
      if (!state.cancelled) {
        state.done = true;
        state.progress.push("Authentication complete!");
      }
    }).catch((err) => {
      if (!state.cancelled) {
        state.done = true;
        state.error = err.message;
      }
      rejectUrl(err);
    });

    try {
      await Promise.race([
        urlPromise,
        new Promise((_, rej) => setTimeout(() => rej(new Error("Timed out waiting for authorize URL — check that the OAuth provider is accessible.")), 20000)),
      ]);
    } catch (err) {
      state.done = true;
      state.error = err.message;
      return { error: err.message, status: 502 };
    }

    return { data: { authorize_url: state.url, status: "awaiting_user" } };
  },

  "GET /api/credentials/:credId/pi-login/poll": async (body, params) => {
    const { credId } = params;
    const state = pendingPiLoginFlows.get(credId);
    if (!state) return { data: { status: "idle" } };
    const status = state.done ? (state.error ? "error" : "complete") : "awaiting_user";
    const needsManualCode = !!(state.needsManualCode || state.resolveManualInput);
    return { data: { status, url: state.url, instructions: state.instructions, progress: [...state.progress], error: state.error, needsManualCode } };
  },

  "POST /api/credentials/:credId/pi-login/code": async (body, params) => {
    const { credId } = params;
    const state = pendingPiLoginFlows.get(credId);
    if (!state) return { error: "No pending pi login for this credential", status: 404 };
    const { code } = body;
    if (!code || typeof code !== "string" || !code.trim()) return { error: "code is required", status: 400 };

    // Anthropic PKCE flow: exchange the code directly
    if (state.anthropicPkce) {
      const { codeVerifier, oauthState } = state.anthropicPkce;
      // Parse code#state format (from platform.claude.com/oauth/code/callback) or plain code
      let parsedCode = code.trim();
      let parsedState = oauthState;
      if (parsedCode.includes("#")) {
        [parsedCode, parsedState] = parsedCode.split("#", 2);
      } else if (parsedCode.startsWith("http")) {
        try {
          const u = new URL(parsedCode);
          parsedCode = u.searchParams.get("code") || parsedCode;
          parsedState = u.searchParams.get("state") || oauthState;
        } catch {}
      }
      try {
        const tokenRes = await fetch(OAUTH_TOKEN_URL, {
          method: "POST",
          headers: { "Content-Type": "application/json", Accept: "application/json" },
          body: JSON.stringify({
            grant_type: "authorization_code",
            client_id: OAUTH_CLIENT_ID,
            code: parsedCode,
            state: parsedState,
            redirect_uri: OAUTH_REDIRECT_URL,
            code_verifier: codeVerifier,
          }),
          signal: AbortSignal.timeout(15000),
        });
        if (!tokenRes.ok) {
          const errText = await tokenRes.text();
          state.done = true;
          state.error = `Token exchange failed (${tokenRes.status}): ${errText}`;
          return { error: state.error, status: 502 };
        }
        const tokenData = await tokenRes.json();
        const piCreds = {
          access: tokenData.access_token,
          refresh: tokenData.refresh_token,
          expires: Date.now() + tokenData.expires_in * 1000 - 5 * 60 * 1000,
        };
        const { readFileSync, writeFileSync, mkdirSync } = await import("node:fs");
        const authJsonPath = "/home/admin/.pi/agent/auth.json";
        let authFile = {};
        try { authFile = JSON.parse(readFileSync(authJsonPath, "utf8")); } catch {}
        authFile["anthropic"] = { type: "oauth", ...piCreds };
        mkdirSync("/home/admin/.pi/agent", { recursive: true });
        writeFileSync(authJsonPath, JSON.stringify(authFile, null, 2), { mode: 0o600 });
        const raw = JSON.stringify({ anthropic: { type: "oauth", ...piCreds } });
        await dbRun("UPDATE credentials SET auth_json = ?, status = 'active', error_message = NULL WHERE id = ?", [raw, credId]);
        state.done = true;
        state.needsManualCode = false;
        state.progress.push("Authentication complete!");
        return { data: { message: "Authentication complete!" } };
      } catch (err) {
        state.done = true;
        state.error = err.message;
        return { error: err.message, status: 500 };
      }
    }

    // Gemini CLI PKCE flow: exchange code for tokens, discover project
    if (state.geminiPkce) {
      const { codeVerifier, clientId, clientSecret, redirectUri } = state.geminiPkce;
      let parsedCode = code.trim();
      // Handle if user pasted a full redirect URL
      if (parsedCode.startsWith("http")) {
        try {
          const u = new URL(parsedCode);
          parsedCode = u.searchParams.get("code") || parsedCode;
        } catch {}
      }
      try {
        const tokenRes = await fetch("https://oauth2.googleapis.com/token", {
          method: "POST",
          headers: { "Content-Type": "application/x-www-form-urlencoded" },
          body: new URLSearchParams({
            client_id: clientId,
            client_secret: clientSecret,
            code: parsedCode,
            grant_type: "authorization_code",
            redirect_uri: redirectUri,
            code_verifier: codeVerifier,
          }),
          signal: AbortSignal.timeout(15000),
        });
        if (!tokenRes.ok) {
          const errText = await tokenRes.text();
          state.done = true;
          state.error = `Google token exchange failed (${tokenRes.status}): ${errText}`;
          return { error: state.error, status: 502 };
        }
        const tokenData = await tokenRes.json();
        if (!tokenData.refresh_token) {
          state.done = true;
          state.error = "No refresh token received from Google. Try again with prompt=consent.";
          return { error: state.error, status: 502 };
        }
        // Get user email
        let email;
        try {
          const infoRes = await fetch("https://www.googleapis.com/oauth2/v1/userinfo?alt=json", {
            headers: { Authorization: `Bearer ${tokenData.access_token}` },
          });
          if (infoRes.ok) email = (await infoRes.json()).email;
        } catch {}
        // Discover Google Cloud project
        let projectId;
        try {
          const loadRes = await fetch("https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", {
            method: "POST",
            headers: {
              Authorization: `Bearer ${tokenData.access_token}`,
              "Content-Type": "application/json",
            },
            body: JSON.stringify({ metadata: { ideType: "IDE_UNSPECIFIED", platform: "PLATFORM_UNSPECIFIED", pluginType: "GEMINI" } }),
          });
          if (loadRes.ok) {
            const loadData = await loadRes.json();
            projectId = loadData.cloudaicompanionProject;
          }
          // If no project and user needs onboarding, try free tier
          if (!projectId) {
            const onboardRes = await fetch("https://cloudcode-pa.googleapis.com/v1internal:onboardUser", {
              method: "POST",
              headers: {
                Authorization: `Bearer ${tokenData.access_token}`,
                "Content-Type": "application/json",
              },
              body: JSON.stringify({ tierId: "free-tier", metadata: { ideType: "IDE_UNSPECIFIED", platform: "PLATFORM_UNSPECIFIED", pluginType: "GEMINI" } }),
            });
            if (onboardRes.ok) {
              const onboardData = await onboardRes.json();
              projectId = onboardData.response?.cloudaicompanionProject?.id || onboardData.cloudaicompanionProject;
            }
          }
        } catch (e) {
          state.progress.push(`Project discovery warning: ${e.message}`);
        }
        const piCreds = {
          type: "oauth",
          refresh: tokenData.refresh_token,
          access: tokenData.access_token,
          expires: Date.now() + tokenData.expires_in * 1000 - 5 * 60 * 1000,
          projectId: projectId || null,
          email: email || null,
        };
        const { readFileSync, writeFileSync, mkdirSync } = await import("node:fs");
        const authJsonPath = "/home/admin/.pi/agent/auth.json";
        let authFile = {};
        try { authFile = JSON.parse(readFileSync(authJsonPath, "utf8")); } catch {}
        authFile["google-gemini-cli"] = piCreds;
        mkdirSync("/home/admin/.pi/agent", { recursive: true });
        writeFileSync(authJsonPath, JSON.stringify(authFile, null, 2), { mode: 0o600 });
        const raw = JSON.stringify({ "google-gemini-cli": piCreds });
        await dbRun("UPDATE credentials SET auth_json = ?, status = 'active', error_message = NULL WHERE id = ?", [raw, credId]);
        state.done = true;
        state.needsManualCode = false;
        state.progress.push("Authentication complete!");
        return { data: { message: `Gemini CLI authenticated${email ? ` (${email})` : ""}${projectId ? `, project: ${projectId}` : ""}` } };
      } catch (err) {
        state.done = true;
        state.error = err.message;
        return { error: err.message, status: 500 };
      }
    }

    // Other backends: resolve the onManualCodeInput promise
    if (!state.resolveManualInput) return { error: "This flow does not require a manual code", status: 400 };
    state.resolveManualInput(code.trim());
    state.resolveManualInput = null;
    state.rejectManualInput = null;
    return { data: { message: "Code accepted, completing authentication…" } };
  },

  "DELETE /api/credentials/:credId": async (body, params) => {
    // Check if any tenant is pinned to this credential
    const pinned = await dbQuery("SELECT id FROM tenants WHERE credential_id = ?", [params.credId]);
    if (pinned.length) {
      return { error: `Credential is pinned to tenant(s): ${pinned.map(t => t.id).join(", ")}`, status: 409 };
    }
    const result = await dbRun("DELETE FROM credentials WHERE id = ?", [params.credId]);
    return result.ok ? { data: { message: "Credential deleted" } } : { error: result.error, status: 500 };
  },

  // --- System ---

  "POST /api/system/rebuild": async () => {
    const result = await nixosRebuildSwitch();
    return result.ok
      ? { data: { message: "nixos-rebuild switch complete", output: result.stdout } }
      : { error: result.stderr, status: 500 };
  },

  // --- Tenant Dashboard Password ---

  "POST /api/tenants/:tenantId/dashboard-password": async (body, params) => {
    const password = generatePassword();
    const hashed = hashPassword(password);
    const result = await dbRun(
      "UPDATE tenants SET dashboard_password = ? WHERE id = ?",
      [hashed, params.tenantId]
    );
    if (!result.ok) return { error: result.error, status: 500 };
    return { data: { message: "Dashboard password set", password, url: `https://${params.tenantId}.devitltd.uk` } };
  },
};

// --- Router ---

function matchRoute(method, url) {
  const path = url.split("?")[0];
  const key = `${method} ${path}`;
  if (routes[key]) return { handler: routes[key], params: {} };

  for (const [pattern, handler] of Object.entries(routes)) {
    const [pMethod, pPath] = pattern.split(" ");
    if (pMethod !== method) continue;
    const pParts = pPath.split("/");
    const uParts = path.split("/");
    if (pParts.length !== uParts.length) continue;
    const params = {};
    let match = true;
    for (let i = 0; i < pParts.length; i++) {
      if (pParts[i].startsWith(":")) {
        params[pParts[i].slice(1)] = uParts[i];
      } else if (pParts[i] !== uParts[i]) {
        match = false;
        break;
      }
    }
    if (match) return { handler, params };
  }
  return null;
}

// --- Skills helpers ---

function parseSkillFrontmatter(content) {
  const match = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
  if (!match) return { name: "", description: "", allowedTools: "", body: content };
  const meta = {};
  for (const line of match[1].split("\n")) {
    const idx = line.indexOf(":");
    if (idx > 0) meta[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
  }
  return {
    name: meta.name || "",
    description: meta.description || "",
    allowedTools: meta["allowed-tools"] || "",
    body: match[2],
  };
}

function buildSkillMd(name, description, allowedTools, body) {
  let md = "---\n";
  md += `name: ${name}\n`;
  md += `description: ${description}\n`;
  if (allowedTools) md += `allowed-tools: ${allowedTools}\n`;
  md += "---\n";
  if (body) md += `\n${body}`;
  return md;
}

async function listSkillsFromDir(dir) {
  const skills = [];
  let entries;
  try { entries = await readdir(dir, { withFileTypes: true }); } catch { return skills; }
  for (const ent of entries) {
    if (!ent.isDirectory() && !ent.isSymbolicLink()) continue;
    try {
      const content = await readFile(`${dir}/${ent.name}/SKILL.md`, "utf8");
      const parsed = parseSkillFrontmatter(content);
      skills.push({ dir: ent.name, ...parsed });
    } catch { /* no SKILL.md, skip */ }
  }
  return skills;
}

// --- Agents helpers ---

function parseAgentFrontmatter(content) {
  const match = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
  if (!match) return { name: "", description: "", model: "", allowedTools: "", maxTurns: "", body: content };
  const meta = {};
  for (const line of match[1].split("\n")) {
    const idx = line.indexOf(":");
    if (idx > 0) meta[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
  }
  return {
    name: meta.name || "",
    description: meta.description || "",
    model: meta.model || "",
    allowedTools: meta["allowed-tools"] || "",
    maxTurns: meta["max-turns"] || "",
    body: match[2],
  };
}

function buildAgentMd(name, description, model, allowedTools, maxTurns, body) {
  let md = "---\n";
  md += `name: ${name}\n`;
  md += `description: ${description}\n`;
  if (model) md += `model: ${model}\n`;
  if (allowedTools) md += `allowed-tools: ${allowedTools}\n`;
  if (maxTurns) md += `max-turns: ${maxTurns}\n`;
  md += "---\n";
  if (body) md += `\n${body}`;
  return md;
}

async function listAgentsFromDir(dir) {
  const agents = [];
  let entries;
  try { entries = await readdir(dir, { withFileTypes: true }); } catch { return agents; }
  for (const ent of entries) {
    if (!ent.isDirectory() && !ent.isSymbolicLink()) continue;
    try {
      const content = await readFile(`${dir}/${ent.name}/AGENT.md`, "utf8");
      const parsed = parseAgentFrontmatter(content);
      agents.push({ dir: ent.name, ...parsed });
    } catch { /* no AGENT.md, skip */ }
  }
  return agents;
}

// --- Projects table ---

let projectsTableCreated = false;
async function ensureProjectsTable() {
  if (projectsTableCreated) return;
  await dbRun(`CREATE TABLE IF NOT EXISTS projects (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    tenant_id TEXT NOT NULL,
    name TEXT NOT NULL,
    description TEXT DEFAULT '',
    status TEXT DEFAULT 'planning',
    folder TEXT DEFAULT '',
    created_at TEXT DEFAULT (datetime('now')),
    updated_at TEXT DEFAULT (datetime('now'))
  )`);
  projectsTableCreated = true;
}

const APP_PREVIEW_MAX_BYTES = 256 * 1024;
const TEXT_FILE_EXTENSIONS = new Set([
  ".c",
  ".cc",
  ".conf",
  ".cpp",
  ".css",
  ".csv",
  ".env",
  ".gitignore",
  ".go",
  ".graphql",
  ".h",
  ".html",
  ".ini",
  ".java",
  ".js",
  ".json",
  ".jsx",
  ".lock",
  ".log",
  ".md",
  ".mjs",
  ".php",
  ".py",
  ".rb",
  ".rs",
  ".sh",
  ".sql",
  ".svg",
  ".svelte",
  ".toml",
  ".ts",
  ".tsx",
  ".txt",
  ".vue",
  ".xml",
  ".yaml",
  ".yml",
]);


function getTenantAppsRoot(tenantId) {
  return path.join(TENANT_HOME_BASE, tenantId, "workspace", "apps");
}

function validateTenantAppName(appName) {
  if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(appName)) {
    throw new Error("Invalid app name");
  }
  return appName;
}

function normalizeBrowsePath(relativePath = "") {
  const cleaned = String(relativePath || "").replace(/^\/+/, "");
  const normalized = path.posix.normalize(cleaned || ".");
  if (normalized.startsWith("../") || normalized === "..") {
    throw new Error("Invalid path");
  }
  return normalized === "." ? "" : normalized;
}

function resolveTenantAppPath(tenantId, appName, relativePath = "") {
  const safeAppName = validateTenantAppName(appName);
  const appRoot = path.join(getTenantAppsRoot(tenantId), safeAppName);
  const normalizedRelativePath = normalizeBrowsePath(relativePath);
  const targetPath = path.resolve(appRoot, normalizedRelativePath || ".");

  if (targetPath !== appRoot && !targetPath.startsWith(appRoot + path.sep)) {
    throw new Error("Invalid path");
  }

  return {
    appName: safeAppName,
    appRoot,
    relativePath: normalizedRelativePath,
    targetPath,
  };
}

function detectAppType(names) {
  if (names.includes("app.yml") || names.includes("nanoclaw.app.yml")) return "nerdctl";
  if (names.includes("index.html")) return "static";
  return "files";
}

function isProbablyTextFile(filePath, buffer) {
  if (TEXT_FILE_EXTENSIONS.has(path.extname(filePath).toLowerCase())) return true;
  if (buffer.includes(0)) return false;
  let suspicious = 0;
  for (const byte of buffer) {
    if (byte === 9 || byte === 10 || byte === 13) continue;
    if (byte < 32 || byte > 126) suspicious += 1;
  }
  return buffer.length === 0 || suspicious / buffer.length < 0.1;
}

async function listTenantAppsFromFs(tenantId) {
  const appsRoot = getTenantAppsRoot(tenantId);
  let entries;
  try {
    entries = await readdir(appsRoot, { withFileTypes: true });
  } catch {
    return [];
  }

  const apps = await Promise.all(entries
    .filter((entry) => entry.isDirectory())
    .map(async (entry) => {
      const appRoot = path.join(appsRoot, entry.name);
      const [appStat, childEntries] = await Promise.all([
        stat(appRoot),
        readdir(appRoot, { withFileTypes: true }).catch(() => []),
      ]);
      const childNames = childEntries.map((child) => child.name);
      return {
        name: entry.name,
        type: detectAppType(childNames),
        file_count: childEntries.filter((child) => child.isFile()).length,
        directory_count: childEntries.filter((child) => child.isDirectory()).length,
        has_app_spec:
          childNames.includes("app.yml") || childNames.includes("nanoclaw.app.yml"),
        updated_at: appStat.mtime.toISOString(),
      };
    }));

  return apps.sort((a, b) => a.name.localeCompare(b.name));
}

async function listTenantAppDirectory(tenantId, appName, relativePath = "") {
  const resolved = resolveTenantAppPath(tenantId, appName, relativePath);
  const targetStat = await stat(resolved.targetPath);
  if (!targetStat.isDirectory()) {
    throw new Error("Path is not a directory");
  }

  const entries = await readdir(resolved.targetPath, { withFileTypes: true });
  const detailedEntries = await Promise.all(entries.map(async (entry) => {
    const entryPath = path.join(resolved.targetPath, entry.name);
    const entryStat = await stat(entryPath);
    const relativeEntryPath = path.posix.join(resolved.relativePath, entry.name);
    return {
      name: entry.name,
      path: relativeEntryPath,
      type: entry.isDirectory() ? "directory" : "file",
      size: entry.isDirectory() ? null : entryStat.size,
      updated_at: entryStat.mtime.toISOString(),
    };
  }));

  detailedEntries.sort((a, b) => {
    if (a.type !== b.type) return a.type === "directory" ? -1 : 1;
    return a.name.localeCompare(b.name);
  });

  return {
    app: resolved.appName,
    path: resolved.relativePath,
    parent_path: resolved.relativePath
      ? path.posix.dirname(resolved.relativePath) === "."
        ? ""
        : path.posix.dirname(resolved.relativePath)
      : null,
    entries: detailedEntries,
  };
}

async function readTenantAppFile(tenantId, appName, relativePath = "") {
  const resolved = resolveTenantAppPath(tenantId, appName, relativePath);
  const fileStat = await stat(resolved.targetPath);
  if (!fileStat.isFile()) {
    throw new Error("Path is not a file");
  }

  if (fileStat.size > APP_PREVIEW_MAX_BYTES) {
    return {
      app: resolved.appName,
      path: resolved.relativePath,
      size: fileStat.size,
      updated_at: fileStat.mtime.toISOString(),
      previewable: false,
      reason: `File is larger than ${APP_PREVIEW_MAX_BYTES} bytes`,
    };
  }

  const content = await readFile(resolved.targetPath);
  if (!isProbablyTextFile(resolved.targetPath, content)) {
    return {
      app: resolved.appName,
      path: resolved.relativePath,
      size: fileStat.size,
      updated_at: fileStat.mtime.toISOString(),
      previewable: false,
      reason: "Binary file preview is not supported",
    };
  }

  return {
    app: resolved.appName,
    path: resolved.relativePath,
    size: fileStat.size,
    updated_at: fileStat.mtime.toISOString(),
    previewable: true,
    content: content.toString("utf8"),
  };
}

// --- Tenant Dashboard Handler ---

async function handleTenantDashboard(req, res, tenantId) {
  const url = req.url;
  const requestUrl = new URL(req.url, "http://localhost");
  const pathname = requestUrl.pathname;


  // PWA routes — served without auth so browsers can fetch manifest/SW
  if (url === "/manifest.json") {
    const manifest = {
      name: `${tenantId} Dashboard`,
      short_name: "Dashboard",
      description: "NanoClaw tenant dashboard",
      start_url: "/",
      display: "standalone",
      background_color: "#08090c",
      theme_color: "#08090c",
      icons: [
        { src: "/icon-192.png", sizes: "192x192", type: "image/png" },
        { src: "/icon-512.png", sizes: "512x512", type: "image/png" }
      ]
    };
    res.writeHead(200, { "Content-Type": "application/manifest+json" });
    return res.end(JSON.stringify(manifest));
  }
  if (url === "/sw.js") {
    const sw = `
const CACHE = 'dashboard-v2';
const SHELL = ['/'];
self.addEventListener('install', e => {
  e.waitUntil(caches.open(CACHE).then(c => c.addAll(SHELL)).then(() => self.skipWaiting()));
});
self.addEventListener('activate', e => {
  e.waitUntil(
    caches.keys().then(keys =>
      Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k)))
    ).then(() => clients.claim())
  );
});
self.addEventListener('fetch', e => {
  if (e.request.method !== 'GET') return;
  const u = new URL(e.request.url);
  if (u.pathname.startsWith('/td/api')) return;
  // Navigation: always network, never cache
  if (e.request.mode === 'navigate') return;
  e.respondWith(fetch(e.request).catch(() => caches.match(e.request)));
});
`.trim();
    res.writeHead(200, { "Content-Type": "application/javascript", "Service-Worker-Allowed": "/" });
    return res.end(sw);
  }
  if (url === "/icon-192.png" || url === "/icon-512.png") {
    const size = url === "/icon-512.png" ? 512 : 192;
    const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 100 100"><rect width="100" height="100" rx="20" fill="#6366f1"/><text x="50" y="67" font-size="56" text-anchor="middle" fill="white" font-family="sans-serif">&#x26A1;</text></svg>`;
    res.writeHead(200, { "Content-Type": "image/svg+xml" });
    return res.end(svg);
  }

  // All tenant dashboard requests require basic auth
  const authed = await authenticateTenant(req, tenantId);
  if (!authed) return sendUnauthorized(res);

  // Tenant dashboard API
  if (url === "/td/api/info") {
    try {
      const rows = await dbQuery("SELECT * FROM tenants WHERE id = ?", [tenantId]);
      if (rows.length === 0) {
        res.writeHead(404, { "Content-Type": "application/json" });
        return res.end(JSON.stringify({ error: "Tenant not found" }));
      }
      const t = rows[0];
      t.groups = await dbQuery(
        "SELECT jid, name, folder, requires_trigger, is_main, hooks_token, hooks_enabled FROM registered_groups WHERE tenant_id = ?",
        [tenantId]
      );
      t.resources = await getTenantResources(tenantId);
      // Don't expose sensitive fields
      delete t.anthropic_key;
      delete t.discord_bot_token;
      delete t.dashboard_password;
      res.writeHead(200, { "Content-Type": "application/json" });
      return res.end(JSON.stringify(t));
    } catch (e) {
      res.writeHead(500, { "Content-Type": "application/json" });
      return res.end(JSON.stringify({ error: e.message }));
    }
  }

  if (url === "/td/api/services" && req.method === "GET") {
    try {
      const tenant = (await dbQuery(
        "SELECT port_range_start, port_range_size FROM tenants WHERE id = ?",
        [tenantId],
      ))[0];
      if (!tenant) {
        res.writeHead(404, { "Content-Type": "application/json" });
        return res.end(JSON.stringify({ error: "Tenant not found" }));
      }

      const services = await getTenantServices(tenantId);
      const caddyfile = await readTenantCaddyfile(tenantId);

      res.writeHead(200, { "Content-Type": "application/json" });
      return res.end(
        JSON.stringify({
          port_range_start: tenant.port_range_start,
          port_range_size: tenant.port_range_size || PORT_RANGE_SIZE,
          ...services,
          caddyfile,
        }),
      );
    } catch (e) {
      res.writeHead(500, { "Content-Type": "application/json" });
      return res.end(JSON.stringify({ error: e.message }));
    }
  }

  if (url === "/td/api/caddyfile" && req.method === "GET") {
    try {
      const content = await readTenantCaddyfile(tenantId);
      if (content === null) {
        res.writeHead(404, { "Content-Type": "application/json" });
        return res.end(JSON.stringify({ error: "No Caddyfile found" }));
      }
      res.writeHead(200, { "Content-Type": "application/json" });
      return res.end(JSON.stringify({ content }));
    } catch (e) {
      res.writeHead(500, { "Content-Type": "application/json" });
      return res.end(JSON.stringify({ error: e.message }));
    }
  }

  // Password change (POST /td/api/change-password)
  if (url === "/td/api/change-password" && req.method === "POST") {
    try {
      const chunks = [];
      for await (const chunk of req) chunks.push(chunk);
      const body = JSON.parse(Buffer.concat(chunks).toString());
      const { new_password } = body;
      if (!new_password || new_password.length < 1) {
        res.writeHead(400, { "Content-Type": "application/json" });
        return res.end(JSON.stringify({ error: "Password cannot be empty" }));
      }
      const result = await dbRun(
        "UPDATE tenants SET dashboard_password = ? WHERE id = ?",
        [hashPassword(new_password), tenantId]
      );
      if (!result.ok) {
        res.writeHead(500, { "Content-Type": "application/json" });
        return res.end(JSON.stringify({ error: result.error }));
      }
      res.writeHead(200, { "Content-Type": "application/json" });
      return res.end(JSON.stringify({ message: "Password updated" }));
    } catch (e) {
      res.writeHead(500, { "Content-Type": "application/json" });
      return res.end(JSON.stringify({ error: e.message }));
    }
  }

  if (pathname === "/td/api/apps" && req.method === "GET") {
    try {
      const apps = await listTenantAppsFromFs(tenantId);
      res.writeHead(200, { "Content-Type": "application/json" });
      return res.end(JSON.stringify({
        apps_root: getTenantAppsRoot(tenantId),
        apps,
      }));
    } catch (e) {
      res.writeHead(500, { "Content-Type": "application/json" });
      return res.end(JSON.stringify({ error: e.message }));
    }
  }

  const appDirMatch = pathname.match(/^\/td\/api\/apps\/([a-zA-Z0-9_-]+)\/files$/);
  if (appDirMatch && req.method === "GET") {
    try {
      const listing = await listTenantAppDirectory(
        tenantId,
        appDirMatch[1],
        requestUrl.searchParams.get("path") || "",
      );
      res.writeHead(200, { "Content-Type": "application/json" });
      return res.end(JSON.stringify(listing));
    } catch (e) {
      const status = e.message === "Invalid app name" || e.message === "Invalid path"
        ? 400
        : e.code === "ENOENT"
          ? 404
          : 500;
      res.writeHead(status, { "Content-Type": "application/json" });
      return res.end(JSON.stringify({ error: e.message }));
    }
  }

  const appFileMatch = pathname.match(/^\/td\/api\/apps\/([a-zA-Z0-9_-]+)\/file$/);
  if (appFileMatch && req.method === "GET") {
    try {
      const file = await readTenantAppFile(
        tenantId,
        appFileMatch[1],
        requestUrl.searchParams.get("path") || "",
      );
      res.writeHead(200, { "Content-Type": "application/json" });
      return res.end(JSON.stringify(file));
    } catch (e) {
      const status = e.message === "Invalid app name" || e.message === "Invalid path"
        ? 400
        : e.code === "ENOENT"
          ? 404
          : 500;
      res.writeHead(status, { "Content-Type": "application/json" });
      return res.end(JSON.stringify({ error: e.message }));
    }
  }

  if (url === "/td/api/disk-usage" && req.method === "GET") {
    try {
      const result = await sh("sudo", [TENANT_RUNTIME_HELPER, tenantId, "runtime-disk-usage"], { timeout: 30000 });
      if (!result.ok) {
        res.writeHead(500, { "Content-Type": "application/json" });
        return res.end(JSON.stringify({ error: result.stderr || "runtime-disk-usage failed" }));
      }
      const data = JSON.parse(result.stdout);
      res.writeHead(200, { "Content-Type": "application/json" });
      return res.end(JSON.stringify(data));
    } catch (e) {
      res.writeHead(500, { "Content-Type": "application/json" });
      return res.end(JSON.stringify({ error: e.message }));
    }
  }

  // Skills API
  const baseSkillsDir = `${NANOCLAW_BASE}/container/skills`;
  const platformSkillsDir = `${NANOCLAW_BASE}/container/skills-platform`;
  const tenantSkillsDir = `${TENANT_HOME_BASE}/${tenantId}/workspace/skills`;

  if (url === "/td/api/skills" && req.method === "GET") {
    try {
      const base = (await listSkillsFromDir(baseSkillsDir)).map(s => ({ ...s, source: "base" }));
      const platform = (await listSkillsFromDir(platformSkillsDir)).map(s => ({ ...s, source: "platform" }));
      const tenant = (await listSkillsFromDir(tenantSkillsDir)).map(s => ({ ...s, source: "tenant" }));
      // Merge: base -> platform -> tenant (tenant can override base, not platform)
      const merged = new Map();
      for (const s of base) merged.set(s.dir, s);
      for (const s of platform) merged.set(s.dir, s);
      for (const s of tenant) merged.set(s.dir, s);
      res.writeHead(200, { "Content-Type": "application/json" });
      return res.end(JSON.stringify([...merged.values()]));
    } catch (e) {
      res.writeHead(500, { "Content-Type": "application/json" });
      return res.end(JSON.stringify({ error: e.message }));
    }
  }

  const skillMatch = url.match(/^\/td\/api\/skills\/([a-zA-Z0-9_-]+)$/);
  if (skillMatch) {
    const skillName = skillMatch[1];
    const tenantPath = `${tenantSkillsDir}/${skillName}/SKILL.md`;
    const basePath = `${baseSkillsDir}/${skillName}/SKILL.md`;

    if (req.method === "GET") {
      try {
        let content, source;
        try { content = await readFile(tenantPath, "utf8"); source = "tenant"; }
        catch { content = await readFile(basePath, "utf8"); source = "base"; }
        const parsed = parseSkillFrontmatter(content);
        res.writeHead(200, { "Content-Type": "application/json" });
        return res.end(JSON.stringify({ dir: skillName, source, ...parsed }));
      } catch {
        res.writeHead(404, { "Content-Type": "application/json" });
        return res.end(JSON.stringify({ error: "Skill not found" }));
      }
    }

    if (req.method === "PUT") {
      try {
        const chunks = [];
        for await (const chunk of req) chunks.push(chunk);
        const body = JSON.parse(Buffer.concat(chunks).toString());
        if (!body.name || !body.description) {
          res.writeHead(400, { "Content-Type": "application/json" });
          return res.end(JSON.stringify({ error: "name and description are required" }));
        }
        const md = buildSkillMd(body.name, body.description, body.allowedTools || "", body.body || "");
        await mkdir(`${tenantSkillsDir}/${skillName}`, { recursive: true });
        await writeFileFs(`${tenantSkillsDir}/${skillName}/SKILL.md`, md, "utf8");
        res.writeHead(200, { "Content-Type": "application/json" });
        return res.end(JSON.stringify({ message: "Skill saved" }));
      } catch (e) {
        res.writeHead(500, { "Content-Type": "application/json" });
        return res.end(JSON.stringify({ error: e.message }));
      }
    }

    if (req.method === "DELETE") {
      try {
        // Only allow deleting tenant skills, not base
        try { await stat(`${tenantSkillsDir}/${skillName}`); } catch {
          res.writeHead(400, { "Content-Type": "application/json" });
          return res.end(JSON.stringify({ error: "Cannot delete base skills" }));
        }
        await rm(`${tenantSkillsDir}/${skillName}`, { recursive: true });
        res.writeHead(200, { "Content-Type": "application/json" });
        return res.end(JSON.stringify({ message: "Skill deleted" }));
      } catch (e) {
        res.writeHead(500, { "Content-Type": "application/json" });
        return res.end(JSON.stringify({ error: e.message }));
      }
    }
  }

  // Agents API
  const baseAgentsDir = `${NANOCLAW_BASE}/container/agents`;
  const tenantAgentsDir = `${TENANT_HOME_BASE}/${tenantId}/nanoclaw/agents`;

  if (url === "/td/api/agents" && req.method === "GET") {
    try {
      const base = (await listAgentsFromDir(baseAgentsDir)).map(a => ({ ...a, source: "base" }));
      const tenant = (await listAgentsFromDir(tenantAgentsDir)).map(a => ({ ...a, source: "tenant" }));
      const merged = new Map();
      for (const a of base) merged.set(a.dir, a);
      for (const a of tenant) merged.set(a.dir, a);
      res.writeHead(200, { "Content-Type": "application/json" });
      return res.end(JSON.stringify([...merged.values()]));
    } catch (e) {
      res.writeHead(500, { "Content-Type": "application/json" });
      return res.end(JSON.stringify({ error: e.message }));
    }
  }

  const agentMatch = url.match(/^\/td\/api\/agents\/([a-zA-Z0-9_-]+)$/);
  if (agentMatch) {
    const agentName = agentMatch[1];
    const tenantPath = `${tenantAgentsDir}/${agentName}/AGENT.md`;
    const basePath = `${baseAgentsDir}/${agentName}/AGENT.md`;

    if (req.method === "GET") {
      try {
        let content, source;
        try { content = await readFile(tenantPath, "utf8"); source = "tenant"; }
        catch { content = await readFile(basePath, "utf8"); source = "base"; }
        const parsed = parseAgentFrontmatter(content);
        res.writeHead(200, { "Content-Type": "application/json" });
        return res.end(JSON.stringify({ dir: agentName, source, ...parsed }));
      } catch {
        res.writeHead(404, { "Content-Type": "application/json" });
        return res.end(JSON.stringify({ error: "Agent not found" }));
      }
    }

    if (req.method === "PUT") {
      try {
        const chunks = [];
        for await (const chunk of req) chunks.push(chunk);
        const body = JSON.parse(Buffer.concat(chunks).toString());
        if (!body.name || !body.description) {
          res.writeHead(400, { "Content-Type": "application/json" });
          return res.end(JSON.stringify({ error: "name and description are required" }));
        }
        const md = buildAgentMd(body.name, body.description, body.model || "", body.allowedTools || "", body.maxTurns || "", body.body || "");
        await mkdir(`${tenantAgentsDir}/${agentName}`, { recursive: true });
        await writeFileFs(`${tenantAgentsDir}/${agentName}/AGENT.md`, md, "utf8");
        res.writeHead(200, { "Content-Type": "application/json" });
        return res.end(JSON.stringify({ message: "Agent saved" }));
      } catch (e) {
        res.writeHead(500, { "Content-Type": "application/json" });
        return res.end(JSON.stringify({ error: e.message }));
      }
    }

    if (req.method === "DELETE") {
      try {
        try { await stat(`${tenantAgentsDir}/${agentName}`); } catch {
          res.writeHead(400, { "Content-Type": "application/json" });
          return res.end(JSON.stringify({ error: "Cannot delete base agents" }));
        }
        await rm(`${tenantAgentsDir}/${agentName}`, { recursive: true });
        res.writeHead(200, { "Content-Type": "application/json" });
        return res.end(JSON.stringify({ message: "Agent deleted" }));
      } catch (e) {
        res.writeHead(500, { "Content-Type": "application/json" });
        return res.end(JSON.stringify({ error: e.message }));
      }
    }
  }

  // Projects API
  if (url === "/td/api/projects" && req.method === "GET") {
    try {
      await ensureProjectsTable();
      const rows = await dbQuery("SELECT * FROM projects WHERE tenant_id = ? ORDER BY created_at DESC", [tenantId]);
      res.writeHead(200, { "Content-Type": "application/json" });
      return res.end(JSON.stringify(rows));
    } catch (e) {
      res.writeHead(500, { "Content-Type": "application/json" });
      return res.end(JSON.stringify({ error: e.message }));
    }
  }

  if (url === "/td/api/projects" && req.method === "POST") {
    try {
      await ensureProjectsTable();
      const chunks = [];
      for await (const chunk of req) chunks.push(chunk);
      const body = JSON.parse(Buffer.concat(chunks).toString());
      if (!body.name) {
        res.writeHead(400, { "Content-Type": "application/json" });
        return res.end(JSON.stringify({ error: "name is required" }));
      }
      await dbRun(
        "INSERT INTO projects (tenant_id, name, description, status, folder) VALUES (?, ?, ?, ?, ?)",
        [tenantId, body.name, body.description || "", body.status || "planning", body.folder || ""]
      );
      res.writeHead(200, { "Content-Type": "application/json" });
      return res.end(JSON.stringify({ message: "Project created" }));
    } catch (e) {
      res.writeHead(500, { "Content-Type": "application/json" });
      return res.end(JSON.stringify({ error: e.message }));
    }
  }

  const projectMatch = url.match(/^\/td\/api\/projects\/(\d+)$/);
  if (projectMatch) {
    const projectId = projectMatch[1];

    if (req.method === "GET") {
      try {
        await ensureProjectsTable();
        const rows = await dbQuery("SELECT * FROM projects WHERE id = ? AND tenant_id = ?", [projectId, tenantId]);
        if (rows.length === 0) {
          res.writeHead(404, { "Content-Type": "application/json" });
          return res.end(JSON.stringify({ error: "Project not found" }));
        }
        res.writeHead(200, { "Content-Type": "application/json" });
        return res.end(JSON.stringify(rows[0]));
      } catch (e) {
        res.writeHead(500, { "Content-Type": "application/json" });
        return res.end(JSON.stringify({ error: e.message }));
      }
    }

    if (req.method === "PATCH") {
      try {
        await ensureProjectsTable();
        const chunks = [];
        for await (const chunk of req) chunks.push(chunk);
        const body = JSON.parse(Buffer.concat(chunks).toString());
        const fields = [];
        const params = [];
        for (const key of ["name", "description", "status", "folder"]) {
          if (body[key] !== undefined) {
            fields.push(`${key} = ?`);
            params.push(body[key]);
          }
        }
        if (fields.length === 0) {
          res.writeHead(400, { "Content-Type": "application/json" });
          return res.end(JSON.stringify({ error: "No fields to update" }));
        }
        fields.push("updated_at = datetime('now')");
        params.push(projectId, tenantId);
        await dbRun(`UPDATE projects SET ${fields.join(", ")} WHERE id = ? AND tenant_id = ?`, params);
        res.writeHead(200, { "Content-Type": "application/json" });
        return res.end(JSON.stringify({ message: "Project updated" }));
      } catch (e) {
        res.writeHead(500, { "Content-Type": "application/json" });
        return res.end(JSON.stringify({ error: e.message }));
      }
    }

    if (req.method === "DELETE") {
      try {
        await ensureProjectsTable();
        await dbRun("DELETE FROM projects WHERE id = ? AND tenant_id = ?", [projectId, tenantId]);
        res.writeHead(200, { "Content-Type": "application/json" });
        return res.end(JSON.stringify({ message: "Project deleted" }));
      } catch (e) {
        res.writeHead(500, { "Content-Type": "application/json" });
        return res.end(JSON.stringify({ error: e.message }));
      }
    }
  }

  // Custom domains (tenant-scoped)
  if (url === "/td/api/custom-domains" && req.method === "GET") {
    const rows = await dbQuery("SELECT * FROM custom_domains WHERE tenant_id = ? ORDER BY created_at DESC", [tenantId]);
    res.writeHead(200, { "Content-Type": "application/json" });
    return res.end(JSON.stringify(rows));
  }

  if (url === "/td/api/custom-domains" && req.method === "POST") {
    const domain = (body.domain || "").toLowerCase().trim();
    const target = (body.target || "dashboard").trim();
    if (!domain) { res.writeHead(400, { "Content-Type": "application/json" }); return res.end(JSON.stringify({ error: "domain is required" })); }
    if (domain.endsWith(".devitltd.uk") || domain === "devitltd.uk" || domain === "mission.null"
        || domain.endsWith(".null") || domain === "localhost" || domain.endsWith(".localhost")
        || /^\d+\.\d+\.\d+\.\d+$/.test(domain) || !domain.includes(".") || !/^[a-z0-9][a-z0-9.-]*[a-z0-9]$/.test(domain)) {
      res.writeHead(400, { "Content-Type": "application/json" });
      return res.end(JSON.stringify({ error: `Domain "${domain}" is not allowed` }));
    }
    const existing = await dbQuery("SELECT * FROM custom_domains WHERE domain = ?", [domain]);
    if (existing.length > 0) {
      res.writeHead(409, { "Content-Type": "application/json" });
      return res.end(JSON.stringify({ error: `Domain "${domain}" is already registered` }));
    }
    const id = generateId();
    const token = Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2);
    const now = new Date().toISOString();
    await dbRun("INSERT INTO custom_domains (id, tenant_id, domain, target, verification_token, status, created_at) VALUES (?,?,?,?,?,'pending',?)",
      [id, tenantId, domain, target, token, now]);
    res.writeHead(200, { "Content-Type": "application/json" });
    return res.end(JSON.stringify({ id, tenant_id: tenantId, domain, target, verification_token: token, status: "pending", created_at: now, verified_at: null }));
  }

  const tdDomainMatch = url.match(/^\/td\/api\/custom-domains\/([^/]+)$/);
  if (tdDomainMatch && req.method === "DELETE") {
    const domainId = tdDomainMatch[1];
    const rows = await dbQuery("SELECT * FROM custom_domains WHERE id = ? AND tenant_id = ?", [domainId, tenantId]);
    if (rows.length === 0) { res.writeHead(404, { "Content-Type": "application/json" }); return res.end(JSON.stringify({ error: "Domain not found" })); }
    await dbRun("DELETE FROM custom_domains WHERE id = ? AND tenant_id = ?", [domainId, tenantId]);
    await sh("sudo", ["systemctl", "kill", "-s", "HUP", "nanoclaw"]).catch(() => {});
    res.writeHead(200, { "Content-Type": "application/json" });
    return res.end(JSON.stringify({ message: "Domain deleted" }));
  }


  // Serve tenant dashboard HTML
  if (url === "/" || url === "/index.html") {
    try {
      let html = await readFile(new URL("./tenant-dashboard.html", import.meta.url), "utf8");
      html = html.replace("__TENANT_ID__", tenantId);
      res.writeHead(200, { "Content-Type": "text/html", "Cache-Control": "no-store" });
      return res.end(html);
    } catch (e) {
      res.writeHead(500, { "Content-Type": "text/plain" });
      return res.end("Dashboard unavailable");
    }
  }


  // Hooks config for tenant
  if (url === "/td/api/hooks" && req.method === "GET") {
    const rows = await dbQuery(
      "SELECT jid, name, folder, hooks_token, hooks_enabled FROM registered_groups WHERE tenant_id = ?",
      [tenantId]
    );
    res.writeHead(200, { "Content-Type": "application/json" });
    return res.end(JSON.stringify(rows));
  }

  if (url.match(/^\/td\/api\/hooks\/rotate$/) && req.method === "POST") {
    const chunks = [];
    for await (const chunk of req) chunks.push(chunk);
    const body = JSON.parse(Buffer.concat(chunks).toString() || "{}");
    const { jid } = body;
    if (!jid) {
      res.writeHead(400, { "Content-Type": "application/json" });
      return res.end(JSON.stringify({ error: "jid is required" }));
    }
    const [group] = await dbQuery("SELECT jid FROM registered_groups WHERE jid = ? AND tenant_id = ?", [jid, tenantId]);
    if (!group) {
      res.writeHead(404, { "Content-Type": "application/json" });
      return res.end(JSON.stringify({ error: "Group not found" }));
    }
    const newToken = randomBytes(32).toString("hex");
    await dbRun("UPDATE registered_groups SET hooks_token = ? WHERE jid = ? AND tenant_id = ?", [newToken, jid, tenantId]);
    res.writeHead(200, { "Content-Type": "application/json" });
    return res.end(JSON.stringify({ hooksToken: newToken }));
  }

  if (url.match(/^\/td\/api\/hooks\/enable$/) && req.method === "POST") {
    const chunks = [];
    for await (const chunk of req) chunks.push(chunk);
    const body = JSON.parse(Buffer.concat(chunks).toString() || "{}");
    const { jid, enabled } = body;
    if (!jid) {
      res.writeHead(400, { "Content-Type": "application/json" });
      return res.end(JSON.stringify({ error: "jid is required" }));
    }
    const [group] = await dbQuery("SELECT jid, hooks_token FROM registered_groups WHERE jid = ? AND tenant_id = ?", [jid, tenantId]);
    if (!group) {
      res.writeHead(404, { "Content-Type": "application/json" });
      return res.end(JSON.stringify({ error: "Group not found" }));
    }
    const token = group.hooks_token || randomBytes(32).toString("hex");
    await dbRun(
      "UPDATE registered_groups SET hooks_enabled = ?, hooks_token = ? WHERE jid = ? AND tenant_id = ?",
      [enabled ? 1 : 0, token, jid, tenantId]
    );
    res.writeHead(200, { "Content-Type": "application/json" });
    return res.end(JSON.stringify({ hooksEnabled: !!enabled, hooksToken: enabled ? token : group.hooks_token }));
  }

  res.writeHead(404);
  res.end("Not found");
}

// --- Server ---

const server = createServer(async (req, res) => {
  const url = req.url;
  const method = req.method;
  const host = (req.headers.host || "").split(":")[0];

  // Tenant dashboard: {tenantId}.devitltd.uk (public HTTPS)
  if (host.endsWith(".devitltd.uk")) {
    const tenantId = host.replace(/\.devitltd\.uk$/, "");
    // Only match single-level subdomains (tenant dashboards), not app subdomains (app.tenant.devitltd.uk)
    if (tenantId && !tenantId.includes(".")) {
      return handleTenantDashboard(req, res, tenantId);
    }
  }

  // CA certificate download (unauthenticated — needed to bootstrap HTTPS trust)
  if (url === "/ca.crt" && method === "GET") {
    const certPath = "/var/lib/caddy/.local/share/caddy/pki/authorities/local/root.crt";
    try {
      const cert = await readFile(certPath);
      res.writeHead(200, {
        "Content-Type": "application/x-x509-ca-cert",
        "Content-Disposition": 'attachment; filename="mission-null-ca.crt"',
      });
      return res.end(cert);
    } catch {
      res.writeHead(404, { "Content-Type": "text/plain" });
      return res.end("CA certificate not found");
    }
  }

  // Admin API (mission.null or direct)
  if (url.startsWith("/api/")) {
    const cookieHeader = req.headers.cookie || "";
    const cookieToken = cookieHeader
      .split(";")
      .map((s) => s.trim())
      .find((p) => p.startsWith("mc_token="))
      ?.slice("mc_token=".length) ?? "";
    const authHeaderToken = req.headers["authorization"]?.replace("Bearer ", "") || "";

    // Cookie-based login endpoint (works even if browser storage is blocked).
    if (url === "/api/auth/login" && method === "POST") {
      const chunks = [];
      for await (const chunk of req) chunks.push(chunk);
      let body = {};
      try { body = JSON.parse(Buffer.concat(chunks).toString()); } catch {}
      const provided = body?.token ? String(body.token) : "";
      if (!provided || provided !== AUTH_TOKEN) {
        res.writeHead(401, { "Content-Type": "application/json" });
        return res.end(JSON.stringify({ error: "Unauthorized" }));
      }
      res.writeHead(200, {
        "Content-Type": "application/json",
        "Set-Cookie": `mc_token=${encodeURIComponent(AUTH_TOKEN)}; Path=/; SameSite=Lax; Max-Age=604800`,
      });
      return res.end(JSON.stringify({ message: "Authenticated" }));
    }

    if (url === "/api/auth/logout" && method === "POST") {
      res.writeHead(200, {
        "Content-Type": "application/json",
        "Set-Cookie": "mc_token=; Path=/; SameSite=Lax; Max-Age=0",
      });
      return res.end(JSON.stringify({ message: "Logged out" }));
    }

    if (url === "/api/auth/session" && method === "GET") {
      const ok = decodeURIComponent(cookieToken || "") === AUTH_TOKEN;
      res.writeHead(ok ? 200 : 401, { "Content-Type": "application/json" });
      return res.end(JSON.stringify(ok ? { status: "ok" } : { error: "Unauthorized" }));
    }

    const token = authHeaderToken || decodeURIComponent(cookieToken || "");
    if (token !== AUTH_TOKEN) {
      res.writeHead(401, { "Content-Type": "application/json" });
      return res.end(JSON.stringify({ error: "Unauthorized" }));
    }

    const route = matchRoute(method, url);
    if (!route) {
      res.writeHead(404, { "Content-Type": "application/json" });
      return res.end(JSON.stringify({ error: "Not found" }));
    }

    let body = {};
    if (method === "POST" || method === "PUT" || method === "DELETE" || method === "PATCH") {
      const chunks = [];
      for await (const chunk of req) chunks.push(chunk);
      try { body = JSON.parse(Buffer.concat(chunks).toString()); } catch {}
    }

    // Parse query string
    const queryStr = url.split("?")[1] || "";
    const query = Object.fromEntries(new URLSearchParams(queryStr));

    try {
      const result = await route.handler(body, route.params, query);
      const status = result.status || (result.error ? 500 : 200);
      res.writeHead(status, { "Content-Type": "application/json" });
      res.end(JSON.stringify(result.error ? { error: result.error } : result.data));
    } catch (e) {
      res.writeHead(500, { "Content-Type": "application/json" });
      res.end(JSON.stringify({ error: e.message }));
    }
    return;
  }

  return serveMcUiAsset(req, res, url);
});

// Proxy WebSocket upgrades to Vite dev server for HMR
if (MC_UI_DEV_URL) {
  server.on("upgrade", async (req, socket, head) => {
    const { request: httpRequest } = await import("node:http");
    const target = new URL(req.url || "/", MC_UI_DEV_URL);
    const proxyReq = httpRequest({
      hostname: target.hostname,
      port: target.port,
      path: target.pathname + target.search,
      method: req.method,
      headers: { ...req.headers, host: target.host },
    });
    proxyReq.on("upgrade", (proxyRes, proxySocket, proxyHead) => {
      socket.write(
        `HTTP/1.1 101 Switching Protocols\r\n` +
        Object.entries(proxyRes.headers).map(([k, v]) => `${k}: ${v}`).join("\r\n") +
        "\r\n\r\n"
      );
      if (proxyHead?.length) proxySocket.unshift(proxyHead);
      proxySocket.pipe(socket).pipe(proxySocket);
    });
    proxyReq.on("error", () => socket.destroy());
    proxyReq.end();
  });
}

const BIND = process.env.MC_BIND || "0.0.0.0";

// Run migrations then start
ensureDashboardPasswordColumn().then(() => ensureTasksSchema()).then(() => ensureCredentialUsageSchema()).then(() => {
  server.listen(PORT, BIND, () => {
    console.log(`NanoClaw Mission Control running at http://${BIND}:${PORT}`);
  });
  // Restore per-tenant proxy iptables rules (PROXY_ALLOW chain is reset on nixos-rebuild/reboot)
  repopulateProxyIptablesRules().catch((e) => console.error("[proxy] startup repopulate failed:", e.message));
});

// Background: capture credential usage snapshots every 30 minutes for UI display.
// Safe even when it fails (network issues / rate limits) — snapshots are best-effort.
setTimeout(() => {
  const run = async () => {
    try {
      await snapshotAllCredentialUsage();
    } catch (e) {
      console.error("credential usage snapshot failed:", e?.message || e);
    }
  };
  run();
  setInterval(run, 30 * 60 * 1000);
}, 5000);
